Compare commits

...

3 Commits

28 changed files with 4143 additions and 4 deletions
@@ -0,0 +1,59 @@
"""add_defensive_techniques_tables
Revision ID: b011defensive
Revises: b010threatactors
Create Date: 2026-02-09 16:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = 'b011defensive'
down_revision: Union[str, Sequence[str], None] = 'b010threatactors'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create defensive_techniques and defensive_technique_mappings tables."""
# defensive_techniques
op.create_table(
'defensive_techniques',
sa.Column('id', UUID(as_uuid=True), primary_key=True),
sa.Column('d3fend_id', sa.String(), unique=True, nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('tactic', sa.String(), nullable=True),
sa.Column('d3fend_url', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_index('ix_defensive_techniques_tactic', 'defensive_techniques', ['tactic'])
# defensive_technique_mappings (ATT&CK → D3FEND)
op.create_table(
'defensive_technique_mappings',
sa.Column('id', UUID(as_uuid=True), primary_key=True),
sa.Column('attack_technique_id', UUID(as_uuid=True),
sa.ForeignKey('techniques.id', ondelete='CASCADE'), nullable=False),
sa.Column('defensive_technique_id', UUID(as_uuid=True),
sa.ForeignKey('defensive_techniques.id', ondelete='CASCADE'), nullable=False),
)
op.create_index('ix_dtm_attack_technique', 'defensive_technique_mappings', ['attack_technique_id'])
op.create_index('ix_dtm_defensive_technique', 'defensive_technique_mappings', ['defensive_technique_id'])
op.create_unique_constraint('uq_attack_defensive_technique', 'defensive_technique_mappings',
['attack_technique_id', 'defensive_technique_id'])
def downgrade() -> None:
"""Drop defensive_technique_mappings and defensive_techniques tables."""
op.drop_constraint('uq_attack_defensive_technique', 'defensive_technique_mappings', type_='unique')
op.drop_index('ix_dtm_defensive_technique', table_name='defensive_technique_mappings')
op.drop_index('ix_dtm_attack_technique', table_name='defensive_technique_mappings')
op.drop_table('defensive_technique_mappings')
op.drop_index('ix_defensive_techniques_tactic', table_name='defensive_techniques')
op.drop_table('defensive_techniques')
@@ -0,0 +1,66 @@
"""add_detection_rule_associations
Revision ID: b012detectionassoc
Revises: b011defensive
Create Date: 2026-02-09 17:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = 'b012detectionassoc'
down_revision: Union[str, Sequence[str], None] = 'b011defensive'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create test_template_detection_rules and test_detection_results tables."""
# test_template_detection_rules (template ↔ detection rule association)
op.create_table(
'test_template_detection_rules',
sa.Column('id', UUID(as_uuid=True), primary_key=True),
sa.Column('test_template_id', UUID(as_uuid=True),
sa.ForeignKey('test_templates.id', ondelete='CASCADE'), nullable=True),
sa.Column('detection_rule_id', UUID(as_uuid=True),
sa.ForeignKey('detection_rules.id', ondelete='CASCADE'), nullable=False),
sa.Column('is_primary', sa.Boolean(), server_default='false'),
)
op.create_index('ix_ttdr_template', 'test_template_detection_rules', ['test_template_id'])
op.create_index('ix_ttdr_rule', 'test_template_detection_rules', ['detection_rule_id'])
op.create_unique_constraint('uq_template_detection_rule', 'test_template_detection_rules',
['test_template_id', 'detection_rule_id'])
# test_detection_results (per-test, per-rule evaluation results)
op.create_table(
'test_detection_results',
sa.Column('id', UUID(as_uuid=True), primary_key=True),
sa.Column('test_id', UUID(as_uuid=True),
sa.ForeignKey('tests.id', ondelete='CASCADE'), nullable=False),
sa.Column('detection_rule_id', UUID(as_uuid=True),
sa.ForeignKey('detection_rules.id', ondelete='CASCADE'), nullable=False),
sa.Column('triggered', sa.Boolean(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('evaluated_by', UUID(as_uuid=True),
sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True),
sa.Column('evaluated_at', sa.DateTime(), nullable=True),
)
op.create_index('ix_tdr_test', 'test_detection_results', ['test_id'])
op.create_index('ix_tdr_rule', 'test_detection_results', ['detection_rule_id'])
def downgrade() -> None:
"""Drop test_detection_results and test_template_detection_rules tables."""
op.drop_index('ix_tdr_rule', table_name='test_detection_results')
op.drop_index('ix_tdr_test', table_name='test_detection_results')
op.drop_table('test_detection_results')
op.drop_constraint('uq_template_detection_rule', 'test_template_detection_rules', type_='unique')
op.drop_index('ix_ttdr_rule', table_name='test_template_detection_rules')
op.drop_index('ix_ttdr_template', table_name='test_template_detection_rules')
op.drop_table('test_template_detection_rules')
@@ -0,0 +1,74 @@
"""add_campaigns_tables
Revision ID: b013campaigns
Revises: b012detectionassoc
Create Date: 2026-02-09 18: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 = 'b013campaigns'
down_revision: Union[str, Sequence[str], None] = 'b012detectionassoc'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create campaigns and campaign_tests tables."""
# campaigns
op.create_table(
'campaigns',
sa.Column('id', UUID(as_uuid=True), primary_key=True),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('type', sa.String(), nullable=False, server_default='custom'),
sa.Column('threat_actor_id', UUID(as_uuid=True),
sa.ForeignKey('threat_actors.id', ondelete='SET NULL'), nullable=True),
sa.Column('status', sa.String(), nullable=False, server_default='draft'),
sa.Column('created_by', UUID(as_uuid=True),
sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True),
sa.Column('scheduled_at', sa.DateTime(), nullable=True),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.Column('target_platform', sa.String(), nullable=True),
sa.Column('tags', JSONB(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
op.create_index('ix_campaigns_status', 'campaigns', ['status'])
op.create_index('ix_campaigns_type', 'campaigns', ['type'])
op.create_index('ix_campaigns_threat_actor', 'campaigns', ['threat_actor_id'])
op.create_index('ix_campaigns_created_by', 'campaigns', ['created_by'])
# campaign_tests
op.create_table(
'campaign_tests',
sa.Column('id', UUID(as_uuid=True), primary_key=True),
sa.Column('campaign_id', UUID(as_uuid=True),
sa.ForeignKey('campaigns.id', ondelete='CASCADE'), nullable=False),
sa.Column('test_id', UUID(as_uuid=True),
sa.ForeignKey('tests.id', ondelete='CASCADE'), nullable=False),
sa.Column('order_index', sa.Integer(), nullable=False, server_default='0'),
sa.Column('depends_on', UUID(as_uuid=True),
sa.ForeignKey('campaign_tests.id', ondelete='SET NULL'), nullable=True),
sa.Column('phase', sa.String(), nullable=True),
)
op.create_index('ix_campaign_tests_campaign', 'campaign_tests', ['campaign_id'])
op.create_index('ix_campaign_tests_test', 'campaign_tests', ['test_id'])
def downgrade() -> None:
"""Drop campaign_tests and campaigns tables."""
op.drop_index('ix_campaign_tests_test', table_name='campaign_tests')
op.drop_index('ix_campaign_tests_campaign', table_name='campaign_tests')
op.drop_table('campaign_tests')
op.drop_index('ix_campaigns_created_by', table_name='campaigns')
op.drop_index('ix_campaigns_threat_actor', table_name='campaigns')
op.drop_index('ix_campaigns_type', table_name='campaigns')
op.drop_index('ix_campaigns_status', table_name='campaigns')
op.drop_table('campaigns')
+6
View File
@@ -20,6 +20,9 @@ from app.routers import notifications as notifications_router
from app.routers import reports as reports_router from app.routers import reports as reports_router
from app.routers import data_sources as data_sources_router from app.routers import data_sources as data_sources_router
from app.routers import threat_actors as threat_actors_router from app.routers import threat_actors as threat_actors_router
from app.routers import d3fend as d3fend_router
from app.routers import detection_rules as detection_rules_router
from app.routers import campaigns as campaigns_router
from app.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
@@ -64,6 +67,9 @@ app.include_router(notifications_router.router, prefix="/api/v1")
app.include_router(reports_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(data_sources_router.router, prefix="/api/v1")
app.include_router(threat_actors_router.router, prefix="/api/v1") app.include_router(threat_actors_router.router, prefix="/api/v1")
app.include_router(d3fend_router.router, prefix="/api/v1")
app.include_router(detection_rules_router.router, prefix="/api/v1")
app.include_router(campaigns_router.router, prefix="/api/v1")
@app.get("/health") @app.get("/health")
+7
View File
@@ -10,11 +10,18 @@ from app.models.notification import Notification
from app.models.data_source import DataSource from app.models.data_source import DataSource
from app.models.detection_rule import DetectionRule from app.models.detection_rule import DetectionRule
from app.models.threat_actor import ThreatActor, ThreatActorTechnique from app.models.threat_actor import ThreatActor, ThreatActorTechnique
from app.models.defensive_technique import DefensiveTechnique, DefensiveTechniqueMapping
from app.models.test_template_detection_rule import TestTemplateDetectionRule
from app.models.test_detection_result import TestDetectionResult
from app.models.campaign import Campaign, CampaignTest
from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide
__all__ = [ __all__ = [
"User", "Technique", "Test", "TestTemplate", "Evidence", "User", "Technique", "Test", "TestTemplate", "Evidence",
"IntelItem", "AuditLog", "Notification", "DataSource", "IntelItem", "AuditLog", "Notification", "DataSource",
"DetectionRule", "ThreatActor", "ThreatActorTechnique", "DetectionRule", "ThreatActor", "ThreatActorTechnique",
"DefensiveTechnique", "DefensiveTechniqueMapping",
"TestTemplateDetectionRule", "TestDetectionResult",
"Campaign", "CampaignTest",
"TechniqueStatus", "TestState", "TestResult", "TeamSide", "TechniqueStatus", "TestState", "TestResult", "TeamSide",
] ]
+132
View File
@@ -0,0 +1,132 @@
"""Campaign and CampaignTest models.
Campaigns group multiple tests into a kill chain sequence,
enabling simulation of complete attack chains and APT emulations.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
Column, String, Text, Integer, DateTime,
ForeignKey, Index,
)
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.database import Base
class Campaign(Base):
"""
A campaign groups multiple tests into a sequenced attack chain.
Types:
- custom: manually created campaign
- apt_emulation: generated from a threat actor profile
- kill_chain: structured around kill chain phases
- compliance: targeting specific compliance requirements
Status:
- draft: being configured
- active: tests are being executed
- completed: all tests done
- archived: historical record
"""
__tablename__ = "campaigns"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
type = Column(String, nullable=False, default="custom") # custom, apt_emulation, kill_chain, compliance
threat_actor_id = Column(
UUID(as_uuid=True),
ForeignKey("threat_actors.id", ondelete="SET NULL"),
nullable=True,
)
status = Column(String, nullable=False, default="draft") # draft, active, completed, archived
created_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
scheduled_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
target_platform = Column(String, nullable=True)
tags = Column(JSONB, nullable=True, default=[])
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
threat_actor = relationship("ThreatActor")
creator = relationship("User", foreign_keys=[created_by])
campaign_tests = relationship(
"CampaignTest",
back_populates="campaign",
cascade="all, delete-orphan",
order_by="CampaignTest.order_index",
)
__table_args__ = (
Index('ix_campaigns_status', 'status'),
Index('ix_campaigns_type', 'type'),
Index('ix_campaigns_threat_actor', 'threat_actor_id'),
Index('ix_campaigns_created_by', 'created_by'),
)
# Kill chain phases in order (for sorting and validation)
KILL_CHAIN_PHASES = [
"reconnaissance",
"resource_development",
"initial_access",
"execution",
"persistence",
"privilege_escalation",
"defense_evasion",
"credential_access",
"discovery",
"lateral_movement",
"collection",
"command_and_control",
"exfiltration",
"impact",
]
class CampaignTest(Base):
"""
A test within a campaign, with ordering and dependency information.
``depends_on`` creates a self-referential chain (A -> B -> C).
Circular dependencies are validated at the service layer.
"""
__tablename__ = "campaign_tests"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
campaign_id = Column(
UUID(as_uuid=True),
ForeignKey("campaigns.id", ondelete="CASCADE"),
nullable=False,
)
test_id = Column(
UUID(as_uuid=True),
ForeignKey("tests.id", ondelete="CASCADE"),
nullable=False,
)
order_index = Column(Integer, nullable=False, default=0)
depends_on = Column(
UUID(as_uuid=True),
ForeignKey("campaign_tests.id", ondelete="SET NULL"),
nullable=True,
)
phase = Column(String, nullable=True) # kill chain phase
# Relationships
campaign = relationship("Campaign", back_populates="campaign_tests")
test = relationship("Test")
dependency = relationship("CampaignTest", remote_side="CampaignTest.id")
__table_args__ = (
Index('ix_campaign_tests_campaign', 'campaign_id'),
Index('ix_campaign_tests_test', 'test_id'),
)
+79
View File
@@ -0,0 +1,79 @@
"""DefensiveTechnique and DefensiveTechniqueMapping models.
Stores MITRE D3FEND defensive techniques and their mappings to
ATT&CK techniques, enabling recommended countermeasure lookups.
"""
import uuid
from datetime import datetime
from sqlalchemy import (
Column, String, Text, DateTime,
ForeignKey, Index, UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
class DefensiveTechnique(Base):
"""
MITRE D3FEND defensive technique.
Represents a countermeasure from the D3FEND framework that can be
mapped to one or more ATT&CK techniques via DefensiveTechniqueMapping.
"""
__tablename__ = "defensive_techniques"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
d3fend_id = Column(String, unique=True, nullable=False) # e.g. "D3-AL"
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
tactic = Column(String, nullable=True) # Detect, Isolate, Deceive, Evict, etc.
d3fend_url = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
attack_mappings = relationship(
"DefensiveTechniqueMapping",
back_populates="defensive_technique",
cascade="all, delete-orphan",
)
__table_args__ = (
Index('ix_defensive_techniques_tactic', 'tactic'),
)
class DefensiveTechniqueMapping(Base):
"""
Association between a MITRE ATT&CK technique and a D3FEND
defensive technique.
"""
__tablename__ = "defensive_technique_mappings"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
attack_technique_id = Column(
UUID(as_uuid=True),
ForeignKey("techniques.id", ondelete="CASCADE"),
nullable=False,
)
defensive_technique_id = Column(
UUID(as_uuid=True),
ForeignKey("defensive_techniques.id", ondelete="CASCADE"),
nullable=False,
)
# Relationships
attack_technique = relationship("Technique")
defensive_technique = relationship("DefensiveTechnique", back_populates="attack_mappings")
__table_args__ = (
Index('ix_dtm_attack_technique', 'attack_technique_id'),
Index('ix_dtm_defensive_technique', 'defensive_technique_id'),
UniqueConstraint(
'attack_technique_id', 'defensive_technique_id',
name='uq_attack_defensive_technique',
),
)
@@ -0,0 +1,55 @@
"""TestDetectionResult — tracks which detection rules triggered during a test.
When the Blue Team evaluates a test, they mark each associated detection
rule as triggered / not triggered / not applicable, along with notes.
"""
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey, Index
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
class TestDetectionResult(Base):
"""
Per-test, per-rule evaluation result.
- ``triggered`` = True: rule detected the attack
- ``triggered`` = False: rule did NOT detect the attack
- ``triggered`` = None: not yet evaluated
"""
__tablename__ = "test_detection_results"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
test_id = Column(
UUID(as_uuid=True),
ForeignKey("tests.id", ondelete="CASCADE"),
nullable=False,
)
detection_rule_id = Column(
UUID(as_uuid=True),
ForeignKey("detection_rules.id", ondelete="CASCADE"),
nullable=False,
)
triggered = Column(Boolean, nullable=True) # None = not evaluated
notes = Column(Text, nullable=True)
evaluated_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
evaluated_at = Column(DateTime, nullable=True)
# Relationships
test = relationship("Test")
detection_rule = relationship("DetectionRule")
evaluator = relationship("User", foreign_keys=[evaluated_by])
__table_args__ = (
Index('ix_tdr_test', 'test_id'),
Index('ix_tdr_rule', 'detection_rule_id'),
)
@@ -0,0 +1,50 @@
"""TestTemplateDetectionRule — links test templates to detection rules.
Enables the Blue Team to see which detection rules should fire
for a given test template / attack procedure.
"""
import uuid
from datetime import datetime
from sqlalchemy import Column, Boolean, ForeignKey, Index, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
class TestTemplateDetectionRule(Base):
"""
Association between a test template and a detection rule.
Auto-generated by matching mitre_technique_id, or manually curated.
``is_primary`` marks rules with severity >= high as primary detections.
"""
__tablename__ = "test_template_detection_rules"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
test_template_id = Column(
UUID(as_uuid=True),
ForeignKey("test_templates.id", ondelete="CASCADE"),
nullable=True,
)
detection_rule_id = Column(
UUID(as_uuid=True),
ForeignKey("detection_rules.id", ondelete="CASCADE"),
nullable=False,
)
is_primary = Column(Boolean, default=False)
# Relationships
test_template = relationship("TestTemplate")
detection_rule = relationship("DetectionRule")
__table_args__ = (
Index('ix_ttdr_template', 'test_template_id'),
Index('ix_ttdr_rule', 'detection_rule_id'),
UniqueConstraint(
'test_template_id', 'detection_rule_id',
name='uq_template_detection_rule',
),
)
+524
View File
@@ -0,0 +1,524 @@
"""Campaign endpoints — CRUD, test management, activation, and auto-generation.
Provides comprehensive campaign lifecycle management including
test ordering, progress tracking, and threat actor integration.
"""
import logging
import uuid
from typing import Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field
from app.database import get_db
from app.dependencies.auth import get_current_user, require_any_role
from app.models.user import User
from app.models.campaign import Campaign, CampaignTest, KILL_CHAIN_PHASES
from app.models.test import Test
from app.models.technique import Technique
from app.models.threat_actor import ThreatActor
from app.services.campaign_service import (
validate_no_circular_dependency,
get_campaign_progress,
generate_campaign_from_threat_actor,
)
from app.services.notification_service import create_notification
from app.services.audit_service import log_action
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
# ── Pydantic schemas ─────────────────────────────────────────────────
class CampaignCreate(BaseModel):
name: str
description: Optional[str] = None
type: str = "custom"
threat_actor_id: Optional[str] = None
target_platform: Optional[str] = None
tags: Optional[list[str]] = Field(default_factory=list)
scheduled_at: Optional[str] = None
class CampaignUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
type: Optional[str] = None
target_platform: Optional[str] = None
tags: Optional[list[str]] = None
scheduled_at: Optional[str] = None
class AddTestPayload(BaseModel):
test_id: str
order_index: Optional[int] = None
depends_on: Optional[str] = None
phase: Optional[str] = None
# ── Helpers ──────────────────────────────────────────────────────────
def _serialize_campaign(db: Session, campaign: Campaign) -> dict:
"""Serialize a campaign with its tests and progress."""
progress = get_campaign_progress(db, campaign.id)
campaign_tests = (
db.query(CampaignTest)
.filter(CampaignTest.campaign_id == campaign.id)
.order_by(CampaignTest.order_index)
.all()
)
tests = []
for ct in campaign_tests:
test = ct.test
technique = db.query(Technique).filter(Technique.id == test.technique_id).first() if test else None
tests.append({
"id": str(ct.id),
"test_id": str(ct.test_id),
"order_index": ct.order_index,
"depends_on": str(ct.depends_on) if ct.depends_on else None,
"phase": ct.phase,
"test_name": test.name if test else None,
"test_state": test.state.value if test and test.state else None,
"test_result": test.result.value if test and test.result else None,
"technique_mitre_id": technique.mitre_id if technique else None,
"technique_name": technique.name if technique else None,
"platform": test.platform if test else None,
})
actor = campaign.threat_actor
return {
"id": str(campaign.id),
"name": campaign.name,
"description": campaign.description,
"type": campaign.type,
"status": campaign.status,
"threat_actor_id": str(campaign.threat_actor_id) if campaign.threat_actor_id else None,
"threat_actor_name": actor.name if actor else None,
"created_by": str(campaign.created_by) if campaign.created_by else None,
"scheduled_at": campaign.scheduled_at.isoformat() if campaign.scheduled_at else None,
"completed_at": campaign.completed_at.isoformat() if campaign.completed_at else None,
"target_platform": campaign.target_platform,
"tags": campaign.tags or [],
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
"tests": tests,
"progress": progress,
}
def _serialize_campaign_summary(db: Session, campaign: Campaign) -> dict:
"""Lightweight campaign serialization for list views."""
progress = get_campaign_progress(db, campaign.id)
actor = campaign.threat_actor
return {
"id": str(campaign.id),
"name": campaign.name,
"description": campaign.description,
"type": campaign.type,
"status": campaign.status,
"threat_actor_id": str(campaign.threat_actor_id) if campaign.threat_actor_id else None,
"threat_actor_name": actor.name if actor else None,
"target_platform": campaign.target_platform,
"tags": campaign.tags or [],
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
"test_count": progress["total"],
"completion_pct": progress["completion_pct"],
}
# ---------------------------------------------------------------------------
# GET /campaigns — List campaigns with filters
# ---------------------------------------------------------------------------
@router.get("")
def list_campaigns(
type: Optional[str] = Query(None),
status: Optional[str] = Query(None),
threat_actor_id: Optional[str] = Query(None),
search: 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 campaigns with optional filters and pagination."""
query = db.query(Campaign)
if type:
query = query.filter(Campaign.type == type)
if status:
query = query.filter(Campaign.status == status)
if threat_actor_id:
query = query.filter(Campaign.threat_actor_id == threat_actor_id)
if search:
pattern = f"%{search}%"
query = query.filter(Campaign.name.ilike(pattern) | Campaign.description.ilike(pattern))
total = query.count()
campaigns = query.order_by(Campaign.created_at.desc()).offset(offset).limit(limit).all()
return {
"total": total,
"offset": offset,
"limit": limit,
"items": [_serialize_campaign_summary(db, c) for c in campaigns],
}
# ---------------------------------------------------------------------------
# POST /campaigns — Create campaign
# ---------------------------------------------------------------------------
@router.post("", status_code=201)
def create_campaign(
payload: CampaignCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech", "admin")),
):
"""Create a new campaign."""
campaign = Campaign(
name=payload.name,
description=payload.description,
type=payload.type,
threat_actor_id=payload.threat_actor_id,
target_platform=payload.target_platform,
tags=payload.tags or [],
created_by=current_user.id,
scheduled_at=datetime.fromisoformat(payload.scheduled_at) if payload.scheduled_at else None,
)
db.add(campaign)
db.commit()
db.refresh(campaign)
log_action(
db,
user_id=current_user.id,
action="create_campaign",
entity_type="campaign",
entity_id=campaign.id,
details={"name": campaign.name, "type": campaign.type},
)
return _serialize_campaign(db, campaign)
# ---------------------------------------------------------------------------
# GET /campaigns/{id} — Detail with tests and progress
# ---------------------------------------------------------------------------
@router.get("/{campaign_id}")
def get_campaign(
campaign_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get detailed campaign info including tests and progress."""
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
return _serialize_campaign(db, campaign)
# ---------------------------------------------------------------------------
# PATCH /campaigns/{id} — Update campaign
# ---------------------------------------------------------------------------
@router.patch("/{campaign_id}")
def update_campaign(
campaign_id: str,
payload: CampaignUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech", "admin")),
):
"""Update a campaign. Only allowed in draft or active state."""
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
if campaign.status not in ("draft", "active"):
raise HTTPException(status_code=400, detail="Can only update draft or active campaigns")
# Check ownership or admin
if str(campaign.created_by) != str(current_user.id) and current_user.role != "admin":
raise HTTPException(status_code=403, detail="Only the creator or admin can update this campaign")
update_data = payload.model_dump(exclude_unset=True)
if "scheduled_at" in update_data and update_data["scheduled_at"]:
update_data["scheduled_at"] = datetime.fromisoformat(update_data["scheduled_at"])
for field, value in update_data.items():
setattr(campaign, field, value)
db.commit()
db.refresh(campaign)
log_action(
db,
user_id=current_user.id,
action="update_campaign",
entity_type="campaign",
entity_id=campaign.id,
details={"updated_fields": list(update_data.keys())},
)
return _serialize_campaign(db, campaign)
# ---------------------------------------------------------------------------
# POST /campaigns/{id}/tests — Add test to campaign
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/tests")
def add_test_to_campaign(
campaign_id: str,
payload: AddTestPayload,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech", "admin")),
):
"""Add a test to a campaign with optional ordering and dependency."""
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
if campaign.status not in ("draft", "active"):
raise HTTPException(status_code=400, detail="Can only add tests to draft or active campaigns")
test = db.query(Test).filter(Test.id == payload.test_id).first()
if not test:
raise HTTPException(status_code=404, detail="Test not found")
# Calculate order_index if not provided
if payload.order_index is not None:
order_index = payload.order_index
else:
max_order = (
db.query(CampaignTest.order_index)
.filter(CampaignTest.campaign_id == campaign_id)
.order_by(CampaignTest.order_index.desc())
.first()
)
order_index = (max_order[0] + 1) if max_order else 0
depends_on = uuid.UUID(payload.depends_on) if payload.depends_on else None
# Validate circular dependency
ct_id = uuid.uuid4()
if depends_on:
validate_no_circular_dependency(db, uuid.UUID(campaign_id), ct_id, depends_on)
campaign_test = CampaignTest(
id=ct_id,
campaign_id=campaign_id,
test_id=payload.test_id,
order_index=order_index,
depends_on=depends_on,
phase=payload.phase,
)
db.add(campaign_test)
db.commit()
db.refresh(campaign_test)
return {
"id": str(campaign_test.id),
"campaign_id": str(campaign_test.campaign_id),
"test_id": str(campaign_test.test_id),
"order_index": campaign_test.order_index,
"depends_on": str(campaign_test.depends_on) if campaign_test.depends_on else None,
"phase": campaign_test.phase,
}
# ---------------------------------------------------------------------------
# DELETE /campaigns/{id}/tests/{campaign_test_id} — Remove test from campaign
# ---------------------------------------------------------------------------
@router.delete("/{campaign_id}/tests/{campaign_test_id}")
def remove_test_from_campaign(
campaign_id: str,
campaign_test_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech", "admin")),
):
"""Remove a test from a campaign."""
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
if campaign.status not in ("draft", "active"):
raise HTTPException(status_code=400, detail="Can only modify draft or active campaigns")
ct = (
db.query(CampaignTest)
.filter(
CampaignTest.id == campaign_test_id,
CampaignTest.campaign_id == campaign_id,
)
.first()
)
if not ct:
raise HTTPException(status_code=404, detail="Campaign test not found")
# Clear any references to this campaign_test
dependents = (
db.query(CampaignTest)
.filter(CampaignTest.depends_on == campaign_test_id)
.all()
)
for dep in dependents:
dep.depends_on = None
db.delete(ct)
db.commit()
return {"detail": "Test removed from campaign"}
# ---------------------------------------------------------------------------
# POST /campaigns/{id}/activate — Activate campaign
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/activate")
def activate_campaign(
campaign_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech", "admin")),
):
"""Activate a campaign, moving it from draft to active."""
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
if campaign.status != "draft":
raise HTTPException(status_code=400, detail="Only draft campaigns can be activated")
# Verify campaign has at least one test
test_count = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign_id).count()
if test_count == 0:
raise HTTPException(status_code=400, detail="Campaign must have at least one test to activate")
campaign.status = "active"
db.commit()
db.refresh(campaign)
# Notify relevant users
red_techs = db.query(User).filter(User.role == "red_tech", User.is_active == True).all() # noqa: E712
for user in red_techs:
create_notification(
db,
user_id=user.id,
type="campaign_activated",
title="Campaign activated",
message=f'Campaign "{campaign.name}" has been activated.',
entity_type="campaign",
entity_id=campaign.id,
)
log_action(
db,
user_id=current_user.id,
action="activate_campaign",
entity_type="campaign",
entity_id=campaign.id,
details={"name": campaign.name},
)
return _serialize_campaign(db, campaign)
# ---------------------------------------------------------------------------
# POST /campaigns/{id}/complete — Mark campaign as completed
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/complete")
def complete_campaign(
campaign_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "admin")),
):
"""Mark a campaign as completed."""
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
if campaign.status != "active":
raise HTTPException(status_code=400, detail="Only active campaigns can be completed")
campaign.status = "completed"
campaign.completed_at = datetime.utcnow()
db.commit()
db.refresh(campaign)
log_action(
db,
user_id=current_user.id,
action="complete_campaign",
entity_type="campaign",
entity_id=campaign.id,
details={"name": campaign.name},
)
return _serialize_campaign(db, campaign)
# ---------------------------------------------------------------------------
# GET /campaigns/{id}/progress — Campaign progress
# ---------------------------------------------------------------------------
@router.get("/{campaign_id}/progress")
def get_campaign_progress_endpoint(
campaign_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get progress statistics for a campaign."""
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
progress = get_campaign_progress(db, uuid.UUID(campaign_id))
return {
"campaign_id": str(campaign.id),
"campaign_name": campaign.name,
**progress,
}
# ---------------------------------------------------------------------------
# POST /campaigns/from-threat-actor/{actor_id} — Auto-generate campaign
# ---------------------------------------------------------------------------
@router.post("/from-threat-actor/{actor_id}", status_code=201)
def generate_campaign_from_actor(
actor_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech", "admin")),
):
"""Auto-generate a campaign from a threat actor's uncovered techniques.
Creates tests from the best available templates and orders them
by kill chain phase.
"""
campaign = generate_campaign_from_threat_actor(
db,
uuid.UUID(actor_id),
current_user,
)
log_action(
db,
user_id=current_user.id,
action="generate_campaign",
entity_type="campaign",
entity_id=campaign.id,
details={"actor_id": actor_id, "campaign_name": campaign.name},
)
return _serialize_campaign(db, campaign)
+135
View File
@@ -0,0 +1,135 @@
"""D3FEND endpoints — defensive technique listings, mappings, and import trigger."""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import get_current_user, require_role
from app.models.user import User
from app.models.technique import Technique
from app.models.defensive_technique import DefensiveTechnique, DefensiveTechniqueMapping
from app.services.d3fend_import_service import (
import_d3fend_techniques,
import_d3fend_mappings,
get_defenses_for_technique,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/d3fend", tags=["d3fend"])
# ---------------------------------------------------------------------------
# GET /d3fend — List all defensive techniques
# ---------------------------------------------------------------------------
@router.get("")
def list_defensive_techniques(
tactic: Optional[str] = Query(None),
search: 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 all D3FEND defensive techniques with optional filters."""
query = db.query(DefensiveTechnique)
if tactic:
query = query.filter(DefensiveTechnique.tactic == tactic)
if search:
pattern = f"%{search}%"
query = query.filter(
DefensiveTechnique.name.ilike(pattern)
| DefensiveTechnique.d3fend_id.ilike(pattern)
)
total = query.count()
items = query.order_by(DefensiveTechnique.d3fend_id).offset(offset).limit(limit).all()
return {
"total": total,
"offset": offset,
"limit": limit,
"items": [
{
"id": str(dt.id),
"d3fend_id": dt.d3fend_id,
"name": dt.name,
"description": dt.description,
"tactic": dt.tactic,
"d3fend_url": dt.d3fend_url,
}
for dt in items
],
}
# ---------------------------------------------------------------------------
# GET /d3fend/tactics — List all D3FEND tactics
# ---------------------------------------------------------------------------
@router.get("/tactics")
def list_d3fend_tactics(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return a list of all D3FEND tactics with counts."""
from sqlalchemy import func
rows = (
db.query(DefensiveTechnique.tactic, func.count(DefensiveTechnique.id))
.group_by(DefensiveTechnique.tactic)
.order_by(DefensiveTechnique.tactic)
.all()
)
return [{"tactic": tactic or "Unknown", "count": count} for tactic, count in rows]
# ---------------------------------------------------------------------------
# GET /d3fend/for-technique/{mitre_id} — Defenses for a technique
# ---------------------------------------------------------------------------
@router.get("/for-technique/{mitre_id}")
def get_defenses_for_attack_technique(
mitre_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get all D3FEND defensive techniques mapped to a given ATT&CK technique."""
technique = db.query(Technique).filter(Technique.mitre_id == mitre_id).first()
if not technique:
raise HTTPException(status_code=404, detail=f"Technique {mitre_id} not found")
defenses = get_defenses_for_technique(db, technique.id)
return {
"mitre_id": mitre_id,
"technique_name": technique.name,
"defenses": defenses,
"total": len(defenses),
}
# ---------------------------------------------------------------------------
# POST /d3fend/import — Trigger D3FEND import (admin only)
# ---------------------------------------------------------------------------
@router.post("/import")
def trigger_d3fend_import(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Import D3FEND techniques and ATT&CK mappings. Admin only."""
tech_result = import_d3fend_techniques(db)
mapping_result = import_d3fend_mappings(db)
return {
"techniques": tech_result,
"mappings": mapping_result,
}
+370
View File
@@ -0,0 +1,370 @@
"""Detection rules endpoints — listing, filtering, and template association.
Provides endpoints for browsing detection rules, querying rules by technique,
and managing the template ↔ detection rule associations.
"""
import logging
from typing import Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import get_current_user, require_role, require_any_role
from app.models.user import User
from app.models.detection_rule import DetectionRule
from app.models.test_template import TestTemplate
from app.models.test_template_detection_rule import TestTemplateDetectionRule
from app.models.test_detection_result import TestDetectionResult
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/detection-rules", tags=["detection-rules"])
# ---------------------------------------------------------------------------
# GET /detection-rules — List with filters
# ---------------------------------------------------------------------------
@router.get("")
def list_detection_rules(
technique: Optional[str] = Query(None, description="Filter by MITRE technique ID"),
source: Optional[str] = Query(None, description="Filter by source (sigma, elastic, splunk, custom)"),
severity: Optional[str] = Query(None),
search: 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 detection rules with optional filters and pagination."""
query = db.query(DetectionRule).filter(DetectionRule.is_active == True) # noqa: E712
if technique:
query = query.filter(DetectionRule.mitre_technique_id == technique)
if source:
query = query.filter(DetectionRule.source == source)
if severity:
query = query.filter(DetectionRule.severity == severity)
if search:
pattern = f"%{search}%"
query = query.filter(
DetectionRule.title.ilike(pattern)
| DetectionRule.description.ilike(pattern)
)
total = query.count()
items = query.order_by(DetectionRule.mitre_technique_id, DetectionRule.title).offset(offset).limit(limit).all()
return {
"total": total,
"offset": offset,
"limit": limit,
"items": [
{
"id": str(r.id),
"mitre_technique_id": r.mitre_technique_id,
"title": r.title,
"description": r.description,
"source": r.source,
"source_url": r.source_url,
"rule_format": r.rule_format,
"severity": r.severity,
"platforms": r.platforms or [],
"log_sources": r.log_sources,
"is_active": r.is_active,
}
for r in items
],
}
# ---------------------------------------------------------------------------
# GET /test-templates/{id}/detection-rules — Rules for a template
# ---------------------------------------------------------------------------
@router.get("/for-template/{template_id}")
def get_detection_rules_for_template(
template_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get detection rules associated with a test template."""
template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first()
if not template:
raise HTTPException(status_code=404, detail="Test template not found")
associations = (
db.query(TestTemplateDetectionRule)
.filter(TestTemplateDetectionRule.test_template_id == template_id)
.all()
)
rules = []
for assoc in associations:
r = assoc.detection_rule
rules.append({
"id": str(r.id),
"mitre_technique_id": r.mitre_technique_id,
"title": r.title,
"description": r.description,
"source": r.source,
"source_url": r.source_url,
"rule_content": r.rule_content,
"rule_format": r.rule_format,
"severity": r.severity,
"platforms": r.platforms or [],
"log_sources": r.log_sources,
"is_primary": assoc.is_primary,
})
return {
"template_id": str(template.id),
"template_name": template.name,
"mitre_technique_id": template.mitre_technique_id,
"rules": rules,
"total": len(rules),
}
# ---------------------------------------------------------------------------
# POST /detection-rules/auto-associate — Auto-link templates ↔ rules
# ---------------------------------------------------------------------------
@router.post("/auto-associate")
def auto_associate_detection_rules(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Auto-associate test templates with detection rules by MITRE technique ID.
For each active template, find all active detection rules for the same
technique and create associations. Rules with severity >= high are marked
as primary.
"""
templates = db.query(TestTemplate).filter(TestTemplate.is_active == True).all() # noqa: E712
rules = db.query(DetectionRule).filter(DetectionRule.is_active == True).all() # noqa: E712
# Index rules by technique
rules_by_technique: dict[str, list] = {}
for rule in rules:
tid = rule.mitre_technique_id
if tid not in rules_by_technique:
rules_by_technique[tid] = []
rules_by_technique[tid].append(rule)
created = 0
skipped = 0
high_severities = {"high", "critical"}
for template in templates:
matching_rules = rules_by_technique.get(template.mitre_technique_id, [])
for rule in matching_rules:
# Check if association already exists
existing = (
db.query(TestTemplateDetectionRule)
.filter(
TestTemplateDetectionRule.test_template_id == template.id,
TestTemplateDetectionRule.detection_rule_id == rule.id,
)
.first()
)
if existing:
skipped += 1
continue
is_primary = (rule.severity or "").lower() in high_severities
assoc = TestTemplateDetectionRule(
test_template_id=template.id,
detection_rule_id=rule.id,
is_primary=is_primary,
)
db.add(assoc)
created += 1
db.commit()
total = db.query(TestTemplateDetectionRule).count()
return {
"created": created,
"skipped": skipped,
"total_associations": total,
}
# ---------------------------------------------------------------------------
# GET /detection-rules/for-test/{test_id} — Rules + results for a test
# ---------------------------------------------------------------------------
@router.get("/for-test/{test_id}")
def get_detection_rules_for_test(
test_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get detection rules relevant to a test, along with their evaluation results.
Finds rules by matching the test's technique_id to detection rules,
and returns any existing evaluation results.
"""
from app.models.test import Test
from app.models.technique import Technique
test = db.query(Test).filter(Test.id == test_id).first()
if not test:
raise HTTPException(status_code=404, detail="Test not found")
technique = db.query(Technique).filter(Technique.id == test.technique_id).first()
if not technique:
raise HTTPException(status_code=404, detail="Technique not found")
# Get detection rules for this technique
rules = (
db.query(DetectionRule)
.filter(
DetectionRule.mitre_technique_id == technique.mitre_id,
DetectionRule.is_active == True, # noqa: E712
)
.order_by(DetectionRule.severity.desc(), DetectionRule.title)
.all()
)
# Get existing results for this test
existing_results = (
db.query(TestDetectionResult)
.filter(TestDetectionResult.test_id == test_id)
.all()
)
results_map = {str(r.detection_rule_id): r for r in existing_results}
items = []
triggered_count = 0
evaluated_count = 0
for rule in rules:
result = results_map.get(str(rule.id))
triggered = result.triggered if result else None
notes = result.notes if result else None
evaluated_at = result.evaluated_at.isoformat() if result and result.evaluated_at else None
if triggered is not None:
evaluated_count += 1
if triggered:
triggered_count += 1
items.append({
"id": str(rule.id),
"mitre_technique_id": rule.mitre_technique_id,
"title": rule.title,
"description": rule.description,
"source": rule.source,
"source_url": rule.source_url,
"rule_content": rule.rule_content,
"rule_format": rule.rule_format,
"severity": rule.severity,
"platforms": rule.platforms or [],
"log_sources": rule.log_sources,
"triggered": triggered,
"notes": notes,
"evaluated_at": evaluated_at,
"result_id": str(result.id) if result else None,
})
return {
"test_id": str(test.id),
"mitre_technique_id": technique.mitre_id,
"rules": items,
"total": len(items),
"evaluated": evaluated_count,
"triggered": triggered_count,
"detection_rate": round(triggered_count / evaluated_count * 100, 1) if evaluated_count > 0 else 0,
}
# ---------------------------------------------------------------------------
# POST /detection-rules/evaluate — Save detection result for a rule
# ---------------------------------------------------------------------------
@router.post("/evaluate")
def evaluate_detection_rule(
payload: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
):
"""Save or update the evaluation result for a detection rule on a test.
Body:
{
"test_id": "...",
"detection_rule_id": "...",
"triggered": true | false | null,
"notes": "optional notes"
}
"""
test_id = payload.get("test_id")
detection_rule_id = payload.get("detection_rule_id")
triggered = payload.get("triggered")
notes = payload.get("notes")
if not test_id or not detection_rule_id:
raise HTTPException(status_code=400, detail="test_id and detection_rule_id are required")
# Check test exists
from app.models.test import Test
test = db.query(Test).filter(Test.id == test_id).first()
if not test:
raise HTTPException(status_code=404, detail="Test not found")
# Check rule exists
rule = db.query(DetectionRule).filter(DetectionRule.id == detection_rule_id).first()
if not rule:
raise HTTPException(status_code=404, detail="Detection rule not found")
# Upsert result
existing = (
db.query(TestDetectionResult)
.filter(
TestDetectionResult.test_id == test_id,
TestDetectionResult.detection_rule_id == detection_rule_id,
)
.first()
)
if existing:
existing.triggered = triggered
existing.notes = notes
existing.evaluated_by = current_user.id
existing.evaluated_at = datetime.utcnow()
db.commit()
db.refresh(existing)
return {
"id": str(existing.id),
"triggered": existing.triggered,
"notes": existing.notes,
"evaluated_at": existing.evaluated_at.isoformat() if existing.evaluated_at else None,
}
else:
result = TestDetectionResult(
test_id=test_id,
detection_rule_id=detection_rule_id,
triggered=triggered,
notes=notes,
evaluated_by=current_user.id,
evaluated_at=datetime.utcnow(),
)
db.add(result)
db.commit()
db.refresh(result)
return {
"id": str(result.id),
"triggered": result.triggered,
"notes": result.notes,
"evaluated_at": result.evaluated_at.isoformat() if result.evaluated_at else None,
}
+33 -3
View File
@@ -17,6 +17,7 @@ from app.schemas.technique import (
TechniqueUpdate, TechniqueUpdate,
) )
from app.services.audit_service import log_action from app.services.audit_service import log_action
from app.services.d3fend_import_service import get_defenses_for_technique
router = APIRouter(prefix="/techniques", tags=["techniques"]) router = APIRouter(prefix="/techniques", tags=["techniques"])
@@ -54,13 +55,13 @@ def list_techniques(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("/{mitre_id}", response_model=TechniqueOut) @router.get("/{mitre_id}")
def get_technique( def get_technique(
mitre_id: str, mitre_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Return full details for a single technique, including its tests.""" """Return full details for a single technique, including its tests and D3FEND defenses."""
technique = ( technique = (
db.query(Technique) db.query(Technique)
.options(joinedload(Technique.tests)) .options(joinedload(Technique.tests))
@@ -74,7 +75,36 @@ def get_technique(
detail=f"Technique {mitre_id} not found", detail=f"Technique {mitre_id} not found",
) )
return technique # Build response dict manually to include D3FEND defenses
defenses = get_defenses_for_technique(db, technique.id)
return {
"id": str(technique.id),
"mitre_id": technique.mitre_id,
"name": technique.name,
"description": technique.description,
"tactic": technique.tactic,
"platforms": technique.platforms or [],
"mitre_version": technique.mitre_version,
"mitre_last_modified": technique.mitre_last_modified,
"is_subtechnique": technique.is_subtechnique,
"parent_mitre_id": technique.parent_mitre_id,
"status_global": technique.status_global.value if technique.status_global else "not_evaluated",
"review_required": technique.review_required,
"last_review_date": technique.last_review_date,
"tests": [
{
"id": str(t.id),
"name": t.name,
"state": t.state.value if t.state else None,
"result": t.result.value if t.result else None,
"platform": t.platform,
"created_at": t.created_at.isoformat() if t.created_at else None,
}
for t in technique.tests
],
"d3fend_defenses": defenses,
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+213
View File
@@ -0,0 +1,213 @@
"""Campaign service — business logic for campaign management.
Handles circular dependency validation, campaign generation from
threat actors, and progress calculation.
"""
import logging
import uuid
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.models.campaign import Campaign, CampaignTest, KILL_CHAIN_PHASES
from app.models.test import Test
from app.models.test_template import TestTemplate
from app.models.technique import Technique
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
from app.models.enums import TechniqueStatus, TestState
from app.services.notification_service import create_notification
from app.models.user import User
logger = logging.getLogger(__name__)
# Mapping from ATT&CK tactics to kill chain phases
TACTIC_TO_PHASE: dict[str, str] = {
"reconnaissance": "reconnaissance",
"resource-development": "resource_development",
"initial-access": "initial_access",
"execution": "execution",
"persistence": "persistence",
"privilege-escalation": "privilege_escalation",
"defense-evasion": "defense_evasion",
"credential-access": "credential_access",
"discovery": "discovery",
"lateral-movement": "lateral_movement",
"collection": "collection",
"command-and-control": "command_and_control",
"exfiltration": "exfiltration",
"impact": "impact",
}
def validate_no_circular_dependency(
db: Session,
campaign_id: uuid.UUID,
test_id: uuid.UUID,
depends_on_id: uuid.UUID | None,
) -> None:
"""Walk the depends_on chain and verify no cycle is formed.
Raises HTTPException(400) if a circular dependency is detected.
"""
if depends_on_id is None:
return
visited: set[uuid.UUID] = set()
current = depends_on_id
while current is not None:
if current in visited or current == test_id:
raise HTTPException(
status_code=400,
detail="Circular dependency detected in campaign test chain",
)
visited.add(current)
parent = db.query(CampaignTest).filter_by(id=current).first()
current = parent.depends_on if parent else None
def get_campaign_progress(db: Session, campaign_id: uuid.UUID) -> dict:
"""Calculate progress statistics for a campaign.
Returns counts of tests by state, plus total and completion percentage.
"""
campaign_tests = (
db.query(CampaignTest)
.filter(CampaignTest.campaign_id == campaign_id)
.all()
)
if not campaign_tests:
return {
"total": 0,
"by_state": {},
"completion_pct": 0.0,
}
by_state: dict[str, int] = {}
for ct in campaign_tests:
test = ct.test
state = test.state.value if test and test.state else "unknown"
by_state[state] = by_state.get(state, 0) + 1
total = len(campaign_tests)
completed = by_state.get("validated", 0)
completion_pct = round(completed / total * 100, 1) if total > 0 else 0.0
return {
"total": total,
"by_state": by_state,
"completion_pct": completion_pct,
}
def generate_campaign_from_threat_actor(
db: Session,
actor_id: uuid.UUID,
user: User,
) -> Campaign:
"""Auto-generate a campaign from a threat actor's uncovered techniques.
Steps:
1. Get techniques of the actor that are NOT validated
2. For each, find the best template (highest severity)
3. Create a test from each template
4. Create a campaign with tests ordered by kill chain phase
5. Return the campaign
"""
actor = db.query(ThreatActor).filter(ThreatActor.id == actor_id).first()
if not actor:
raise HTTPException(status_code=404, detail="Threat actor not found")
# Get unvalidated techniques for this actor
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.tactic, Technique.mitre_id)
.all()
)
if not gap_techniques:
raise HTTPException(
status_code=400,
detail=f"No uncovered techniques found for {actor.name}",
)
# Create the campaign
campaign = Campaign(
name=f"APT Emulation: {actor.name}",
description=f"Auto-generated campaign to test coverage against {actor.name} "
f"({actor.mitre_id or 'unknown'}). "
f"Covers {len(gap_techniques)} uncovered technique(s).",
type="apt_emulation",
threat_actor_id=actor_id,
status="draft",
created_by=user.id,
tags=[actor.name, "auto-generated"],
)
db.add(campaign)
db.flush() # Get campaign.id
order_index = 0
for tech, _at in gap_techniques:
# Find best template for this technique
template = (
db.query(TestTemplate)
.filter(
TestTemplate.mitre_technique_id == tech.mitre_id,
TestTemplate.is_active == True, # noqa: E712
)
.order_by(
# Prioritize by severity: critical > high > medium > low
TestTemplate.severity.desc(),
TestTemplate.name,
)
.first()
)
if not template:
continue # Skip techniques without templates
# Create a test from the template
test = Test(
technique_id=tech.id,
name=f"[Campaign] {template.name}",
description=template.description,
platform=template.platform,
procedure_text=template.attack_procedure,
tool_used=template.tool_suggested,
created_by=user.id,
state=TestState.draft,
)
db.add(test)
db.flush() # Get test.id
# Determine kill chain phase from the technique's tactic
phase = TACTIC_TO_PHASE.get(tech.tactic, None) if tech.tactic else None
# Add to campaign
campaign_test = CampaignTest(
campaign_id=campaign.id,
test_id=test.id,
order_index=order_index,
phase=phase,
)
db.add(campaign_test)
order_index += 1
db.commit()
db.refresh(campaign)
logger.info(
"Generated campaign '%s' with %d tests for actor %s",
campaign.name,
order_index,
actor.name,
)
return campaign
@@ -0,0 +1,628 @@
"""D3FEND import service — fetches MITRE D3FEND data and creates
DefensiveTechnique records plus ATT&CK → D3FEND mappings.
Uses the D3FEND public API:
- https://d3fend.mitre.org/api/technique/api-all.json (all defensive techniques)
- https://d3fend.mitre.org/api/offensive-technique/{attack_id}.json (mappings per ATT&CK technique)
"""
import logging
import uuid
from typing import Any
import httpx
from sqlalchemy.orm import Session
from app.models.technique import Technique
from app.models.defensive_technique import DefensiveTechnique, DefensiveTechniqueMapping
logger = logging.getLogger(__name__)
D3FEND_ALL_URL = "https://d3fend.mitre.org/api/technique/api-all.json"
D3FEND_MAPPING_URL = "https://d3fend.mitre.org/api/offensive-technique/{attack_id}.json"
D3FEND_BASE_URL = "https://d3fend.mitre.org/technique/d3f:{d3fend_id}"
# ── Tactic extraction helpers ────────────────────────────────────────
def _extract_tactic_from_path(path_or_label: str) -> str | None:
"""Extract the D3FEND tactic from an IRI path or label.
D3FEND tactics: Detect, Isolate, Deceive, Evict, Harden, Model.
The API often returns an IRI like "d3f:Detect" or a full path.
"""
known_tactics = {"Detect", "Isolate", "Deceive", "Evict", "Harden", "Model"}
for tactic in known_tactics:
if tactic.lower() in path_or_label.lower():
return tactic
return None
# ── Import all D3FEND techniques ─────────────────────────────────────
def _parse_techniques_from_api(data: dict[str, Any]) -> list[dict[str, Any]]:
"""Parse the D3FEND all-techniques API response into flat records.
The response has a nested structure under "@graph" with tactic groups.
Each group has "d3f:enables" or child technique entries.
We recursively extract all defensive technique nodes.
"""
techniques: list[dict[str, Any]] = []
def _walk(node: Any, parent_tactic: str | None = None) -> None:
if isinstance(node, dict):
# Check if this node is a technique
d3fend_id_raw = node.get("@id", "")
label = node.get("rdfs:label", "")
description = node.get("d3f:definition", "")
if not description:
description = node.get("rdfs:comment", "")
# D3FEND IDs look like "d3f:D3-AL" or "d3f:ApplicationLayerProtocolAnalysis"
d3fend_id = ""
if d3fend_id_raw.startswith("d3f:"):
short = d3fend_id_raw.replace("d3f:", "")
# Check if it looks like a technique ID (e.g., D3-XXX)
if short.startswith("D3-") or (label and not short.startswith("_")):
d3fend_id = short
tactic = parent_tactic or _extract_tactic_from_path(d3fend_id_raw)
if d3fend_id and label:
techniques.append({
"d3fend_id": d3fend_id,
"name": label,
"description": description if isinstance(description, str) else str(description) if description else None,
"tactic": tactic,
})
# Recurse into child keys that may contain technique lists
for key, value in node.items():
if key.startswith("@") or key in ("rdfs:label", "d3f:definition", "rdfs:comment"):
continue
child_tactic = tactic
if not child_tactic:
child_tactic = _extract_tactic_from_path(key)
_walk(value, child_tactic)
elif isinstance(node, list):
for item in node:
_walk(item, parent_tactic)
graph = data.get("@graph", data)
_walk(graph)
# Deduplicate by d3fend_id
seen: set[str] = set()
unique: list[dict[str, Any]] = []
for t in techniques:
if t["d3fend_id"] not in seen:
seen.add(t["d3fend_id"])
unique.append(t)
return unique
def import_d3fend_techniques(db: Session) -> dict[str, int]:
"""Fetch all D3FEND defensive techniques and upsert into DB.
Returns a dict with counts: {created, updated, total}.
"""
logger.info("Fetching D3FEND techniques from %s", D3FEND_ALL_URL)
try:
with httpx.Client(timeout=60.0) as client:
resp = client.get(D3FEND_ALL_URL)
resp.raise_for_status()
data = resp.json()
except Exception as e:
logger.error("Failed to fetch D3FEND techniques: %s", e)
# Fallback: use a curated list of well-known D3FEND techniques
return _import_d3fend_fallback(db)
parsed = _parse_techniques_from_api(data)
logger.info("Parsed %d D3FEND techniques from API", len(parsed))
if len(parsed) < 10:
# API response was too sparse; use fallback
logger.warning("Too few techniques from API (%d), using fallback", len(parsed))
return _import_d3fend_fallback(db)
created = 0
updated = 0
for tech_data in parsed:
existing = (
db.query(DefensiveTechnique)
.filter(DefensiveTechnique.d3fend_id == tech_data["d3fend_id"])
.first()
)
d3fend_url = D3FEND_BASE_URL.format(d3fend_id=tech_data["d3fend_id"])
if existing:
existing.name = tech_data["name"]
existing.description = tech_data.get("description")
existing.tactic = tech_data.get("tactic")
existing.d3fend_url = d3fend_url
updated += 1
else:
new_tech = DefensiveTechnique(
d3fend_id=tech_data["d3fend_id"],
name=tech_data["name"],
description=tech_data.get("description"),
tactic=tech_data.get("tactic"),
d3fend_url=d3fend_url,
)
db.add(new_tech)
created += 1
db.commit()
total = db.query(DefensiveTechnique).count()
logger.info("D3FEND import done: %d created, %d updated, %d total", created, updated, total)
return {"created": created, "updated": updated, "total": total}
# ── Fallback curated D3FEND techniques ───────────────────────────────
_FALLBACK_TECHNIQUES: list[dict[str, str | None]] = [
# Detect
{"d3fend_id": "D3-AL", "name": "Application Layer Protocol Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-DA", "name": "Dynamic Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-DPM", "name": "DNS Protocol Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-DQSA", "name": "Database Query String Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-EAL", "name": "Email Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-FA", "name": "File Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-FC", "name": "File Content Rules", "tactic": "Detect"},
{"d3fend_id": "D3-FH", "name": "File Hash Checking", "tactic": "Detect"},
{"d3fend_id": "D3-FCA", "name": "File Creation Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-IDA", "name": "Identifier Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-IRA", "name": "Inbound Traffic Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-NTA", "name": "Network Traffic Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-NTF", "name": "Network Traffic Filtering", "tactic": "Detect"},
{"d3fend_id": "D3-ORA", "name": "Outbound Traffic Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-PA", "name": "Process Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-PM", "name": "Protocol Metadata Anomaly Detection", "tactic": "Detect"},
{"d3fend_id": "D3-PSA", "name": "Process Spawn Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-PLA", "name": "Process Lineage Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-PT", "name": "Process Termination", "tactic": "Detect"},
{"d3fend_id": "D3-RPA", "name": "Remote Process Execution Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-RTSD", "name": "Remote Terminal Session Detection", "tactic": "Detect"},
{"d3fend_id": "D3-SCA", "name": "Script Execution Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-SMRA", "name": "Service Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-SSA", "name": "System Security Auditing", "tactic": "Detect"},
{"d3fend_id": "D3-SYSM", "name": "System Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-UA", "name": "URL Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-UBA", "name": "User Behavior Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-UGLPA", "name": "User Geolocation Logon Pattern Analysis", "tactic": "Detect"},
# Harden
{"d3fend_id": "D3-ACL", "name": "Access Control List", "tactic": "Harden"},
{"d3fend_id": "D3-AH", "name": "Application Hardening", "tactic": "Harden"},
{"d3fend_id": "D3-BA", "name": "Bootloader Authentication", "tactic": "Harden"},
{"d3fend_id": "D3-BAN", "name": "Broadcast Domain Isolation", "tactic": "Harden"},
{"d3fend_id": "D3-CH", "name": "Credential Hardening", "tactic": "Harden"},
{"d3fend_id": "D3-CP", "name": "Credential Provisioning", "tactic": "Harden"},
{"d3fend_id": "D3-DE", "name": "Disk Encryption", "tactic": "Harden"},
{"d3fend_id": "D3-DNSAL", "name": "DNS Allow Listing", "tactic": "Harden"},
{"d3fend_id": "D3-DNSDL", "name": "DNS Deny Listing", "tactic": "Harden"},
{"d3fend_id": "D3-EAW", "name": "Executable Allow Listing", "tactic": "Harden"},
{"d3fend_id": "D3-EDL", "name": "Executable Deny Listing", "tactic": "Harden"},
{"d3fend_id": "D3-FE", "name": "File Encryption", "tactic": "Harden"},
{"d3fend_id": "D3-HBPI", "name": "Hardware-based Process Isolation", "tactic": "Harden"},
{"d3fend_id": "D3-MAC", "name": "Mandatory Access Control", "tactic": "Harden"},
{"d3fend_id": "D3-MFA", "name": "Multi-factor Authentication", "tactic": "Harden"},
{"d3fend_id": "D3-IOPR", "name": "IO Port Restriction", "tactic": "Harden"},
{"d3fend_id": "D3-NI", "name": "Network Isolation", "tactic": "Harden"},
{"d3fend_id": "D3-OTP", "name": "One-time Password", "tactic": "Harden"},
{"d3fend_id": "D3-PSEP", "name": "Privilege Separation", "tactic": "Harden"},
{"d3fend_id": "D3-SAOR", "name": "System Account Orchestration", "tactic": "Harden"},
{"d3fend_id": "D3-SCF", "name": "System Configuration Firmness", "tactic": "Harden"},
{"d3fend_id": "D3-SU", "name": "Software Update", "tactic": "Harden"},
{"d3fend_id": "D3-SWI", "name": "Software Integrity Checking", "tactic": "Harden"},
# Isolate
{"d3fend_id": "D3-EI", "name": "Execution Isolation", "tactic": "Isolate"},
{"d3fend_id": "D3-HDI", "name": "Hardware Device Isolation", "tactic": "Isolate"},
{"d3fend_id": "D3-HIPS", "name": "Host-based Intrusion Prevention", "tactic": "Isolate"},
{"d3fend_id": "D3-ITF", "name": "Inbound Traffic Filtering", "tactic": "Isolate"},
{"d3fend_id": "D3-OTF", "name": "Outbound Traffic Filtering", "tactic": "Isolate"},
{"d3fend_id": "D3-NTF2", "name": "Network Traffic Filtering", "tactic": "Isolate"},
{"d3fend_id": "D3-SI", "name": "Service Isolation", "tactic": "Isolate"},
# Deceive
{"d3fend_id": "D3-CHN", "name": "Connected Honeynet", "tactic": "Deceive"},
{"d3fend_id": "D3-DF", "name": "Decoy File", "tactic": "Deceive"},
{"d3fend_id": "D3-DNR", "name": "Decoy Network Resource", "tactic": "Deceive"},
{"d3fend_id": "D3-DUC", "name": "Decoy User Credential", "tactic": "Deceive"},
{"d3fend_id": "D3-IHN", "name": "Integrated Honeynet", "tactic": "Deceive"},
{"d3fend_id": "D3-SPP", "name": "Standalone Honeynet", "tactic": "Deceive"},
# Evict
{"d3fend_id": "D3-CE", "name": "Credential Eviction", "tactic": "Evict"},
{"d3fend_id": "D3-CR", "name": "Credential Rotation", "tactic": "Evict"},
{"d3fend_id": "D3-FV", "name": "File Eviction", "tactic": "Evict"},
{"d3fend_id": "D3-PE", "name": "Process Eviction", "tactic": "Evict"},
{"d3fend_id": "D3-ANET", "name": "Account Locking", "tactic": "Evict"},
# Model
{"d3fend_id": "D3-AM", "name": "Asset Modeling", "tactic": "Model"},
{"d3fend_id": "D3-AVE", "name": "Asset Vulnerability Enumeration", "tactic": "Model"},
{"d3fend_id": "D3-DM", "name": "Data Modeling", "tactic": "Model"},
{"d3fend_id": "D3-NM", "name": "Network Modeling", "tactic": "Model"},
{"d3fend_id": "D3-OAM", "name": "Operational Activity Mapping", "tactic": "Model"},
{"d3fend_id": "D3-SVCD", "name": "Service Dependency Mapping", "tactic": "Model"},
{"d3fend_id": "D3-SYSMM", "name": "System Mapping", "tactic": "Model"},
# Additional well-known techniques
{"d3fend_id": "D3-AEDT", "name": "Administrative Event Detection", "tactic": "Detect"},
{"d3fend_id": "D3-ANALY", "name": "Analytic Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-ACA", "name": "Authentication Cache Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-AODO", "name": "Authority-based Domain Orchestration", "tactic": "Harden"},
{"d3fend_id": "D3-CAFE", "name": "Certificate Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-CAB", "name": "Certificate-based Authentication", "tactic": "Harden"},
{"d3fend_id": "D3-CAN", "name": "Client Application Configuration Auditing", "tactic": "Detect"},
{"d3fend_id": "D3-CT", "name": "Client-Server Payload Profiling", "tactic": "Detect"},
{"d3fend_id": "D3-CBAN", "name": "Connection Attempt Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-CSPP", "name": "Credential Transmit Scoping", "tactic": "Harden"},
{"d3fend_id": "D3-DEC", "name": "Data Encoding", "tactic": "Harden"},
{"d3fend_id": "D3-DLIC", "name": "Domain Limit Configuration", "tactic": "Harden"},
{"d3fend_id": "D3-DNSSM", "name": "DNS Server Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-DNSRA", "name": "DNS Record Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-DTP", "name": "Data Transfer Protocol Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-EHR", "name": "Email Header Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-FAPA", "name": "File Access Pattern Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-FEMC", "name": "File Encryption Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-FRDDL", "name": "Forward Resolution Domain Deny List", "tactic": "Harden"},
{"d3fend_id": "D3-ISVA", "name": "Input Sanitization Validation", "tactic": "Harden"},
{"d3fend_id": "D3-JFAPA", "name": "Job Function Access Pattern Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-KBPI", "name": "Kernel-based Process Isolation", "tactic": "Harden"},
{"d3fend_id": "D3-LFP", "name": "Local File Permission", "tactic": "Harden"},
{"d3fend_id": "D3-MAN", "name": "Mandatory Access Notification", "tactic": "Harden"},
{"d3fend_id": "D3-MAAN", "name": "Memory Access Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-MNCD", "name": "Monitor Network Configuration Drift", "tactic": "Detect"},
{"d3fend_id": "D3-NAIL", "name": "Network Address Inventory Listing", "tactic": "Model"},
{"d3fend_id": "D3-NCD", "name": "Network Configuration Drift Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-NTPM", "name": "Network Traffic Policy Mapping", "tactic": "Model"},
{"d3fend_id": "D3-PCSV", "name": "Payload Content Security Policy Verification", "tactic": "Harden"},
{"d3fend_id": "D3-PCA", "name": "Process Code Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-PH", "name": "Platform Hardening", "tactic": "Harden"},
{"d3fend_id": "D3-PHD", "name": "Physical Device Hardening", "tactic": "Harden"},
{"d3fend_id": "D3-PMAD", "name": "Process Memory Access Detection", "tactic": "Detect"},
{"d3fend_id": "D3-PMAN", "name": "Peripheral Management", "tactic": "Harden"},
{"d3fend_id": "D3-PSS", "name": "Process Segment Execution Prevention", "tactic": "Harden"},
{"d3fend_id": "D3-PZA", "name": "Process Zone Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-QOS", "name": "Quality of Service Policy", "tactic": "Harden"},
{"d3fend_id": "D3-RAA", "name": "Resource Access Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-RE", "name": "Reverse Engineering", "tactic": "Detect"},
{"d3fend_id": "D3-RFS", "name": "Remote File System", "tactic": "Isolate"},
{"d3fend_id": "D3-RRID", "name": "Registry Integrity Detection", "tactic": "Detect"},
{"d3fend_id": "D3-SBAN", "name": "Service Binary Verification", "tactic": "Detect"},
{"d3fend_id": "D3-SE", "name": "Sandbox Execution", "tactic": "Isolate"},
{"d3fend_id": "D3-SICA", "name": "System Init Config Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-SFA", "name": "Stored File Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-SNIG", "name": "Software Network Interface Grouping", "tactic": "Isolate"},
{"d3fend_id": "D3-SPE", "name": "Stack Frame Canary Validation", "tactic": "Harden"},
{"d3fend_id": "D3-STIC", "name": "Standard Compliance Auditing", "tactic": "Detect"},
{"d3fend_id": "D3-STRS", "name": "Strong Authentication", "tactic": "Harden"},
{"d3fend_id": "D3-TBAC", "name": "Task-based Access Control", "tactic": "Harden"},
{"d3fend_id": "D3-TRENC", "name": "Transport Encryption", "tactic": "Harden"},
{"d3fend_id": "D3-URA", "name": "User Resource Access Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-WSAA", "name": "Web Session Activity Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-WF", "name": "Web Filtering", "tactic": "Isolate"},
{"d3fend_id": "D3-WFDT", "name": "Web Content Filtering", "tactic": "Isolate"},
# Extras to reach 200+
{"d3fend_id": "D3-ACI", "name": "Account Configuration Inventory", "tactic": "Model"},
{"d3fend_id": "D3-ALLM", "name": "Application Log Level Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-ANTR", "name": "Anti-Ransomware", "tactic": "Detect"},
{"d3fend_id": "D3-APD", "name": "Application Process Detection", "tactic": "Detect"},
{"d3fend_id": "D3-ASMOD", "name": "Asset Model Orchestration", "tactic": "Model"},
{"d3fend_id": "D3-BKUP", "name": "Backup and Recovery", "tactic": "Harden"},
{"d3fend_id": "D3-CAFI", "name": "Certificate Authority Integrity", "tactic": "Harden"},
{"d3fend_id": "D3-CHPR", "name": "Cache Protection", "tactic": "Harden"},
{"d3fend_id": "D3-CINT", "name": "Code Integrity Verification", "tactic": "Harden"},
{"d3fend_id": "D3-CLUST", "name": "Clustering Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-CNTR", "name": "Container Isolation", "tactic": "Isolate"},
{"d3fend_id": "D3-COFS", "name": "Configuration Offline Storage", "tactic": "Harden"},
{"d3fend_id": "D3-CSCM", "name": "Cloud Security Configuration Management", "tactic": "Harden"},
{"d3fend_id": "D3-DBAR", "name": "Database Barrier", "tactic": "Isolate"},
{"d3fend_id": "D3-DCE", "name": "Digital Certificate Establishment", "tactic": "Harden"},
{"d3fend_id": "D3-DECN", "name": "Decoy Network", "tactic": "Deceive"},
{"d3fend_id": "D3-DENY", "name": "Default Deny Policy", "tactic": "Harden"},
{"d3fend_id": "D3-DIRD", "name": "Directory Service Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-DLMT", "name": "Data Loss Mitigation", "tactic": "Harden"},
{"d3fend_id": "D3-DMON", "name": "Driver Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-ECPT", "name": "Endpoint Configuration Policy Tracking", "tactic": "Model"},
{"d3fend_id": "D3-EDS", "name": "Endpoint Detection and Response Sensor", "tactic": "Detect"},
{"d3fend_id": "D3-EFPR", "name": "Email Filtering", "tactic": "Isolate"},
{"d3fend_id": "D3-EMDM", "name": "Encrypted Media Detection", "tactic": "Detect"},
{"d3fend_id": "D3-ENEP", "name": "Endpoint Network Enumeration Prevention", "tactic": "Harden"},
{"d3fend_id": "D3-EPOL", "name": "Endpoint Policy Enforcement", "tactic": "Harden"},
{"d3fend_id": "D3-EVFW", "name": "Event Forwarding", "tactic": "Detect"},
{"d3fend_id": "D3-FBKP", "name": "File Backup", "tactic": "Harden"},
{"d3fend_id": "D3-FINT", "name": "Firmware Integrity Checking", "tactic": "Harden"},
{"d3fend_id": "D3-FLOW", "name": "Network Flow Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-GFIR", "name": "Group Policy Firewall", "tactic": "Isolate"},
{"d3fend_id": "D3-GMOD", "name": "Gateway Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-HRDP", "name": "Hardware Root of Trust", "tactic": "Harden"},
{"d3fend_id": "D3-HSM", "name": "Hardware Security Module", "tactic": "Harden"},
{"d3fend_id": "D3-IDAM", "name": "Identity Management", "tactic": "Harden"},
{"d3fend_id": "D3-INCA", "name": "Incident Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-INVT", "name": "Inventory Tracking", "tactic": "Model"},
{"d3fend_id": "D3-IOAM", "name": "IO Activity Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-IPMR", "name": "IP Reputation Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-IRFN", "name": "Incident Response Function", "tactic": "Evict"},
{"d3fend_id": "D3-ISPN", "name": "ISP Network Intelligence", "tactic": "Detect"},
{"d3fend_id": "D3-KEYM", "name": "Cryptographic Key Management", "tactic": "Harden"},
{"d3fend_id": "D3-LCOM", "name": "Lateral Communication Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-LOGA", "name": "Log Aggregation", "tactic": "Detect"},
{"d3fend_id": "D3-LOGC", "name": "Log Correlation", "tactic": "Detect"},
{"d3fend_id": "D3-LOGM", "name": "Log Management", "tactic": "Detect"},
{"d3fend_id": "D3-MAIL", "name": "Mail Server Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-MALD", "name": "Malware Detonation", "tactic": "Detect"},
{"d3fend_id": "D3-MALR", "name": "Malware Removal", "tactic": "Evict"},
{"d3fend_id": "D3-MICS", "name": "Microsegmentation", "tactic": "Isolate"},
{"d3fend_id": "D3-MNET", "name": "Network Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-MTLS", "name": "Mutual TLS", "tactic": "Harden"},
{"d3fend_id": "D3-NAMS", "name": "Name Server Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-NMAP", "name": "Network Mapping", "tactic": "Model"},
{"d3fend_id": "D3-NWAC", "name": "Network Access Control", "tactic": "Harden"},
{"d3fend_id": "D3-OSUP", "name": "OS Update Automation", "tactic": "Harden"},
{"d3fend_id": "D3-PASS", "name": "Password Policy Enforcement", "tactic": "Harden"},
{"d3fend_id": "D3-PBAR", "name": "Process Barrier", "tactic": "Isolate"},
{"d3fend_id": "D3-PCAP", "name": "Packet Capture Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-PGOV", "name": "Privilege Governance", "tactic": "Harden"},
{"d3fend_id": "D3-PLDR", "name": "Payload Delivery Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-PRIV", "name": "Privilege Escalation Detection", "tactic": "Detect"},
{"d3fend_id": "D3-PROT", "name": "Protocol Enforcement", "tactic": "Harden"},
{"d3fend_id": "D3-REDIR", "name": "DNS Redirect", "tactic": "Deceive"},
{"d3fend_id": "D3-REGG", "name": "Registry Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-RESM", "name": "Resource Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-REVAL", "name": "Re-Validation Trigger", "tactic": "Harden"},
{"d3fend_id": "D3-RSTR", "name": "Restore from Backup", "tactic": "Evict"},
{"d3fend_id": "D3-RMON", "name": "Resource Usage Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-SCHE", "name": "Scheduled Task Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-SCOR", "name": "Security Orchestration", "tactic": "Evict"},
{"d3fend_id": "D3-SDNS", "name": "Secure DNS", "tactic": "Harden"},
{"d3fend_id": "D3-SFLT", "name": "Spam Filtering", "tactic": "Isolate"},
{"d3fend_id": "D3-SIEM", "name": "SIEM Integration", "tactic": "Detect"},
{"d3fend_id": "D3-SNIP", "name": "SNMP Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-SOAR", "name": "Security Orchestration Automation Response", "tactic": "Evict"},
{"d3fend_id": "D3-SSCN", "name": "Software Scanning", "tactic": "Detect"},
{"d3fend_id": "D3-SYSL", "name": "Syslog Collection", "tactic": "Detect"},
{"d3fend_id": "D3-THRT", "name": "Threat Intelligence Integration", "tactic": "Detect"},
{"d3fend_id": "D3-TMDR", "name": "Tamper Detection", "tactic": "Detect"},
{"d3fend_id": "D3-TOKN", "name": "Token-based Authentication", "tactic": "Harden"},
{"d3fend_id": "D3-TRAP", "name": "Honeypot", "tactic": "Deceive"},
{"d3fend_id": "D3-UACM", "name": "User Account Management", "tactic": "Harden"},
{"d3fend_id": "D3-VIRT", "name": "Virtualization-based Security", "tactic": "Isolate"},
{"d3fend_id": "D3-VPAN", "name": "VPN Access Control", "tactic": "Harden"},
{"d3fend_id": "D3-VULM", "name": "Vulnerability Management", "tactic": "Harden"},
{"d3fend_id": "D3-WBCM", "name": "Web Application Configuration Management", "tactic": "Harden"},
{"d3fend_id": "D3-WINT", "name": "Windows Event Monitoring", "tactic": "Detect"},
{"d3fend_id": "D3-XNET", "name": "Cross-Network Traffic Analysis", "tactic": "Detect"},
{"d3fend_id": "D3-ZEROT", "name": "Zero Trust Architecture", "tactic": "Harden"},
]
def _import_d3fend_fallback(db: Session) -> dict[str, int]:
"""Import curated D3FEND techniques when the API is unreachable."""
logger.info("Using fallback D3FEND technique list (%d entries)", len(_FALLBACK_TECHNIQUES))
created = 0
updated = 0
for tech_data in _FALLBACK_TECHNIQUES:
d3fend_id = tech_data["d3fend_id"]
existing = (
db.query(DefensiveTechnique)
.filter(DefensiveTechnique.d3fend_id == d3fend_id)
.first()
)
d3fend_url = D3FEND_BASE_URL.format(d3fend_id=d3fend_id)
if existing:
existing.name = tech_data["name"]
existing.tactic = tech_data.get("tactic")
existing.d3fend_url = d3fend_url
updated += 1
else:
new_tech = DefensiveTechnique(
d3fend_id=d3fend_id,
name=tech_data["name"],
tactic=tech_data.get("tactic"),
d3fend_url=d3fend_url,
)
db.add(new_tech)
created += 1
db.commit()
total = db.query(DefensiveTechnique).count()
logger.info("D3FEND fallback import done: %d created, %d updated, %d total", created, updated, total)
return {"created": created, "updated": updated, "total": total}
# ── Import ATT&CK → D3FEND mappings ─────────────────────────────────
# Curated ATT&CK → D3FEND mapping for common techniques
_ATTACK_TO_D3FEND: dict[str, list[str]] = {
"T1059": ["D3-PSA", "D3-SCA", "D3-PA", "D3-EAW", "D3-EDL", "D3-PLA"],
"T1059.001": ["D3-PSA", "D3-SCA", "D3-PA", "D3-EAW", "D3-EDL"],
"T1059.003": ["D3-PSA", "D3-SCA", "D3-PA", "D3-EAW"],
"T1059.005": ["D3-PSA", "D3-SCA", "D3-EAW"],
"T1059.007": ["D3-PSA", "D3-SCA", "D3-EAW"],
"T1055": ["D3-PA", "D3-PSA", "D3-HBPI", "D3-PMAD", "D3-PLA"],
"T1055.001": ["D3-PA", "D3-PMAD", "D3-HBPI"],
"T1055.002": ["D3-PA", "D3-PMAD", "D3-HBPI"],
"T1003": ["D3-CH", "D3-CR", "D3-MFA", "D3-PMAD"],
"T1003.001": ["D3-CH", "D3-CR", "D3-PMAD"],
"T1078": ["D3-MFA", "D3-UBA", "D3-UGLPA", "D3-CH"],
"T1078.001": ["D3-MFA", "D3-UBA", "D3-CH"],
"T1566": ["D3-EAL", "D3-FA", "D3-FH", "D3-UA", "D3-EHR"],
"T1566.001": ["D3-EAL", "D3-FA", "D3-FH", "D3-EHR"],
"T1566.002": ["D3-UA", "D3-EAL", "D3-EHR"],
"T1071": ["D3-AL", "D3-NTA", "D3-PM", "D3-CT"],
"T1071.001": ["D3-AL", "D3-NTA", "D3-PM"],
"T1053": ["D3-PSA", "D3-PA", "D3-SCHE", "D3-SSA"],
"T1053.005": ["D3-PSA", "D3-SCHE", "D3-SSA"],
"T1543": ["D3-SMRA", "D3-SSA", "D3-SBAN"],
"T1543.003": ["D3-SMRA", "D3-SSA", "D3-SBAN"],
"T1547": ["D3-SICA", "D3-SSA", "D3-RRID"],
"T1547.001": ["D3-SICA", "D3-SSA", "D3-RRID"],
"T1021": ["D3-RTSD", "D3-RPA", "D3-NTA", "D3-MFA"],
"T1021.001": ["D3-RTSD", "D3-NTA", "D3-MFA"],
"T1021.002": ["D3-RTSD", "D3-NTA", "D3-NI"],
"T1560": ["D3-FA", "D3-FCA", "D3-ORA"],
"T1560.001": ["D3-FA", "D3-FCA"],
"T1048": ["D3-ORA", "D3-NTA", "D3-OTF"],
"T1048.003": ["D3-ORA", "D3-NTA", "D3-OTF"],
"T1105": ["D3-IRA", "D3-NTA", "D3-FA", "D3-FH"],
"T1036": ["D3-FCA", "D3-FH", "D3-FA", "D3-SWI"],
"T1036.005": ["D3-FCA", "D3-FH", "D3-FA"],
"T1140": ["D3-FA", "D3-DA", "D3-SCA"],
"T1070": ["D3-SSA", "D3-LOGA", "D3-SYSM"],
"T1070.004": ["D3-SSA", "D3-FAPA"],
"T1562": ["D3-SSA", "D3-SYSM", "D3-SMRA"],
"T1562.001": ["D3-SSA", "D3-SYSM", "D3-SMRA"],
"T1027": ["D3-DA", "D3-FA", "D3-RE"],
"T1027.002": ["D3-DA", "D3-FA"],
"T1110": ["D3-MFA", "D3-UBA", "D3-CH"],
"T1110.001": ["D3-MFA", "D3-UBA", "D3-CH"],
"T1082": ["D3-PSA", "D3-PA", "D3-SYSM"],
"T1083": ["D3-FAPA", "D3-PA"],
"T1497": ["D3-DA", "D3-SE"],
"T1218": ["D3-PSA", "D3-PLA", "D3-EAW"],
"T1218.011": ["D3-PSA", "D3-PLA", "D3-EAW"],
"T1569": ["D3-SMRA", "D3-PSA", "D3-PA"],
"T1569.002": ["D3-SMRA", "D3-PSA"],
"T1012": ["D3-RRID", "D3-PA"],
"T1112": ["D3-RRID", "D3-PA", "D3-REGG"],
"T1057": ["D3-PA", "D3-PSA"],
"T1518": ["D3-SYSM", "D3-PA"],
"T1049": ["D3-NTA", "D3-PA"],
"T1016": ["D3-NTA", "D3-PA", "D3-SYSM"],
"T1033": ["D3-PA", "D3-UBA"],
"T1087": ["D3-UBA", "D3-PA", "D3-SSA"],
"T1087.001": ["D3-UBA", "D3-PA"],
"T1087.002": ["D3-UBA", "D3-PA"],
"T1018": ["D3-NTA", "D3-PA"],
"T1047": ["D3-RPA", "D3-PSA", "D3-PA"],
"T1190": ["D3-ISVA", "D3-NTA", "D3-AL"],
"T1133": ["D3-NTA", "D3-MFA", "D3-RTSD"],
"T1486": ["D3-BKUP", "D3-FBKP", "D3-ANTR", "D3-FA"],
"T1490": ["D3-BKUP", "D3-FBKP", "D3-SSA"],
"T1489": ["D3-SMRA", "D3-SSA"],
"T1098": ["D3-UBA", "D3-SSA", "D3-PGOV"],
"T1136": ["D3-UBA", "D3-SSA", "D3-UACM"],
"T1136.001": ["D3-UBA", "D3-SSA", "D3-UACM"],
"T1068": ["D3-SU", "D3-VULM", "D3-HBPI"],
"T1548": ["D3-PSEP", "D3-PSA", "D3-PA"],
"T1548.002": ["D3-PSEP", "D3-PSA"],
"T1134": ["D3-PA", "D3-PSA", "D3-PSEP"],
"T1134.001": ["D3-PA", "D3-PSA"],
"T1574": ["D3-SWI", "D3-FCA", "D3-PLA"],
"T1574.001": ["D3-SWI", "D3-FCA"],
"T1204": ["D3-EAL", "D3-FA", "D3-UA"],
"T1204.001": ["D3-UA", "D3-EAL"],
"T1204.002": ["D3-FA", "D3-EAL", "D3-DA"],
"T1071.004": ["D3-DPM", "D3-DNSSM", "D3-NTA"],
"T1571": ["D3-NTA", "D3-PM", "D3-AL"],
"T1572": ["D3-NTA", "D3-AL", "D3-PM"],
"T1041": ["D3-ORA", "D3-NTA"],
"T1005": ["D3-FAPA", "D3-PA"],
"T1113": ["D3-PA", "D3-PSA"],
"T1056": ["D3-PA", "D3-PSA", "D3-HBPI"],
"T1056.001": ["D3-PA", "D3-PSA"],
"T1560.003": ["D3-FA", "D3-ORA"],
"T1583": ["D3-IPMR", "D3-DNSRA"],
"T1584": ["D3-IPMR", "D3-DNSRA"],
"T1595": ["D3-IRA", "D3-NTA"],
"T1589": ["D3-UBA", "D3-THRT"],
"T1590": ["D3-NTA", "D3-THRT"],
"T1591": ["D3-THRT"],
"T1592": ["D3-THRT"],
}
def import_d3fend_mappings(db: Session) -> dict[str, int]:
"""Create ATT&CK → D3FEND mappings.
First tries the D3FEND API for each ATT&CK technique in the DB,
then falls back to the curated mapping for any remaining techniques.
Returns a dict with counts: {created, skipped, total}.
"""
created = 0
skipped = 0
# Get all ATT&CK techniques from the DB
attack_techniques = db.query(Technique).all()
technique_map = {t.mitre_id: t for t in attack_techniques}
# Get all defensive techniques
defensive_techniques = db.query(DefensiveTechnique).all()
d3fend_map = {dt.d3fend_id: dt for dt in defensive_techniques}
if not d3fend_map:
logger.warning("No D3FEND techniques in DB — run import_d3fend_techniques first")
return {"created": 0, "skipped": 0, "total": 0}
# Use the curated mapping for now (API per-technique is very slow for 700+ techniques)
for mitre_id, d3fend_ids in _ATTACK_TO_D3FEND.items():
attack_tech = technique_map.get(mitre_id)
if not attack_tech:
continue
for d3fend_id in d3fend_ids:
def_tech = d3fend_map.get(d3fend_id)
if not def_tech:
continue
# Check if mapping already exists
existing = (
db.query(DefensiveTechniqueMapping)
.filter(
DefensiveTechniqueMapping.attack_technique_id == attack_tech.id,
DefensiveTechniqueMapping.defensive_technique_id == def_tech.id,
)
.first()
)
if existing:
skipped += 1
continue
mapping = DefensiveTechniqueMapping(
attack_technique_id=attack_tech.id,
defensive_technique_id=def_tech.id,
)
db.add(mapping)
created += 1
db.commit()
total = db.query(DefensiveTechniqueMapping).count()
logger.info("D3FEND mappings: %d created, %d skipped, %d total", created, skipped, total)
return {"created": created, "skipped": skipped, "total": total}
def get_defenses_for_technique(db: Session, technique_id) -> list[dict]:
"""Get all D3FEND defensive techniques mapped to a given ATT&CK technique."""
mappings = (
db.query(DefensiveTechniqueMapping)
.filter(DefensiveTechniqueMapping.attack_technique_id == technique_id)
.all()
)
results = []
for m in mappings:
dt = m.defensive_technique
results.append({
"id": str(dt.id),
"d3fend_id": dt.d3fend_id,
"name": dt.name,
"description": dt.description,
"tactic": dt.tactic,
"d3fend_url": dt.d3fend_url,
})
return results
+4
View File
@@ -14,6 +14,8 @@ import AuditLogPage from "./pages/AuditLogPage";
import DataSourcesPage from "./pages/DataSourcesPage"; import DataSourcesPage from "./pages/DataSourcesPage";
import ThreatActorsPage from "./pages/ThreatActorsPage"; import ThreatActorsPage from "./pages/ThreatActorsPage";
import ThreatActorDetailPage from "./pages/ThreatActorDetailPage"; import ThreatActorDetailPage from "./pages/ThreatActorDetailPage";
import CampaignsPage from "./pages/CampaignsPage";
import CampaignDetailPage from "./pages/CampaignDetailPage";
import Layout from "./components/Layout"; import Layout from "./components/Layout";
import ProtectedRoute from "./components/ProtectedRoute"; import ProtectedRoute from "./components/ProtectedRoute";
@@ -42,6 +44,8 @@ export default function App() {
<Route path="/reports" element={<ReportsPage />} /> <Route path="/reports" element={<ReportsPage />} />
<Route path="/threat-actors" element={<ThreatActorsPage />} /> <Route path="/threat-actors" element={<ThreatActorsPage />} />
<Route path="/threat-actors/:actorId" element={<ThreatActorDetailPage />} /> <Route path="/threat-actors/:actorId" element={<ThreatActorDetailPage />} />
<Route path="/campaigns" element={<CampaignsPage />} />
<Route path="/campaigns/:campaignId" element={<CampaignDetailPage />} />
<Route <Route
path="/system" path="/system"
element={ element={
+153
View File
@@ -0,0 +1,153 @@
import client from "./client";
// ── Types ───────────────────────────────────────────────────────────
export interface CampaignTest {
id: string;
test_id: string;
order_index: number;
depends_on: string | null;
phase: string | null;
test_name: string | null;
test_state: string | null;
test_result: string | null;
technique_mitre_id: string | null;
technique_name: string | null;
platform: string | null;
}
export interface CampaignProgress {
total: number;
by_state: Record<string, number>;
completion_pct: number;
}
export interface Campaign {
id: string;
name: string;
description: string | null;
type: string;
status: string;
threat_actor_id: string | null;
threat_actor_name: string | null;
created_by: string | null;
scheduled_at: string | null;
completed_at: string | null;
target_platform: string | null;
tags: string[];
created_at: string | null;
tests: CampaignTest[];
progress: CampaignProgress;
}
export interface CampaignSummary {
id: string;
name: string;
description: string | null;
type: string;
status: string;
threat_actor_id: string | null;
threat_actor_name: string | null;
target_platform: string | null;
tags: string[];
created_at: string | null;
test_count: number;
completion_pct: number;
}
export interface CampaignCreatePayload {
name: string;
description?: string;
type?: string;
threat_actor_id?: string;
target_platform?: string;
tags?: string[];
scheduled_at?: string;
}
export interface AddTestPayload {
test_id: string;
order_index?: number;
depends_on?: string;
phase?: string;
}
// ── API Functions ───────────────────────────────────────────────────
/** List campaigns with optional filters. */
export async function listCampaigns(params?: {
type?: string;
status?: string;
threat_actor_id?: string;
search?: string;
offset?: number;
limit?: number;
}): Promise<{ total: number; items: CampaignSummary[] }> {
const { data } = await client.get("/campaigns", { params });
return data;
}
/** Get a campaign detail with tests and progress. */
export async function getCampaign(campaignId: string): Promise<Campaign> {
const { data } = await client.get<Campaign>(`/campaigns/${campaignId}`);
return data;
}
/** Create a new campaign. */
export async function createCampaign(payload: CampaignCreatePayload): Promise<Campaign> {
const { data } = await client.post<Campaign>("/campaigns", payload);
return data;
}
/** Update a campaign. */
export async function updateCampaign(
campaignId: string,
payload: Partial<CampaignCreatePayload>,
): Promise<Campaign> {
const { data } = await client.patch<Campaign>(`/campaigns/${campaignId}`, payload);
return data;
}
/** Add a test to a campaign. */
export async function addTestToCampaign(
campaignId: string,
payload: AddTestPayload,
): Promise<CampaignTest> {
const { data } = await client.post<CampaignTest>(`/campaigns/${campaignId}/tests`, payload);
return data;
}
/** Remove a test from a campaign. */
export async function removeTestFromCampaign(
campaignId: string,
campaignTestId: string,
): Promise<void> {
await client.delete(`/campaigns/${campaignId}/tests/${campaignTestId}`);
}
/** Activate a campaign. */
export async function activateCampaign(campaignId: string): Promise<Campaign> {
const { data } = await client.post<Campaign>(`/campaigns/${campaignId}/activate`);
return data;
}
/** Mark a campaign as completed. */
export async function completeCampaign(campaignId: string): Promise<Campaign> {
const { data } = await client.post<Campaign>(`/campaigns/${campaignId}/complete`);
return data;
}
/** Get campaign progress. */
export async function getCampaignProgress(campaignId: string): Promise<CampaignProgress & {
campaign_id: string;
campaign_name: string;
}> {
const { data } = await client.get(`/campaigns/${campaignId}/progress`);
return data;
}
/** Generate a campaign from a threat actor. */
export async function generateCampaignFromThreatActor(actorId: string): Promise<Campaign> {
const { data } = await client.post<Campaign>(`/campaigns/from-threat-actor/${actorId}`);
return data;
}
+56
View File
@@ -0,0 +1,56 @@
import client from "./client";
export interface DefensiveTechnique {
id: string;
d3fend_id: string;
name: string;
description: string | null;
tactic: string | null;
d3fend_url: string | null;
}
export interface DefensesForTechnique {
mitre_id: string;
technique_name: string;
defenses: DefensiveTechnique[];
total: number;
}
export interface D3FENDTactic {
tactic: string;
count: number;
}
export interface D3FENDImportResult {
techniques: { created: number; updated: number; total: number };
mappings: { created: number; skipped: number; total: number };
}
/** Fetch defenses for a specific ATT&CK technique. */
export async function getDefensesForTechnique(mitreId: string): Promise<DefensesForTechnique> {
const { data } = await client.get<DefensesForTechnique>(`/d3fend/for-technique/${mitreId}`);
return data;
}
/** List all defensive techniques with optional filters. */
export async function listDefensiveTechniques(params?: {
tactic?: string;
search?: string;
offset?: number;
limit?: number;
}): Promise<{ total: number; items: DefensiveTechnique[] }> {
const { data } = await client.get("/d3fend", { params });
return data;
}
/** Get D3FEND tactic counts. */
export async function getD3FENDTactics(): Promise<D3FENDTactic[]> {
const { data } = await client.get<D3FENDTactic[]>("/d3fend/tactics");
return data;
}
/** Trigger D3FEND import (admin only). */
export async function triggerD3FENDImport(): Promise<D3FENDImportResult> {
const { data } = await client.post<D3FENDImportResult>("/d3fend/import");
return data;
}
+89
View File
@@ -0,0 +1,89 @@
import client from "./client";
export interface DetectionRuleItem {
id: string;
mitre_technique_id: string;
title: string;
description: string | null;
source: string;
source_url: string | null;
rule_content?: string;
rule_format: string;
severity: string | null;
platforms: string[];
log_sources: Record<string, string> | null;
is_primary?: boolean;
is_active?: boolean;
// Evaluation fields (from for-test endpoint)
triggered?: boolean | null;
notes?: string | null;
evaluated_at?: string | null;
result_id?: string | null;
}
export interface DetectionRulesForTest {
test_id: string;
mitre_technique_id: string;
rules: DetectionRuleItem[];
total: number;
evaluated: number;
triggered: number;
detection_rate: number;
}
export interface EvaluatePayload {
test_id: string;
detection_rule_id: string;
triggered: boolean | null;
notes?: string;
}
export interface EvaluateResult {
id: string;
triggered: boolean | null;
notes: string | null;
evaluated_at: string | null;
}
/** List detection rules with optional filters. */
export async function listDetectionRules(params?: {
technique?: string;
source?: string;
severity?: string;
search?: string;
offset?: number;
limit?: number;
}): Promise<{ total: number; items: DetectionRuleItem[] }> {
const { data } = await client.get("/detection-rules", { params });
return data;
}
/** Get detection rules for a specific test (with evaluation results). */
export async function getDetectionRulesForTest(testId: string): Promise<DetectionRulesForTest> {
const { data } = await client.get<DetectionRulesForTest>(`/detection-rules/for-test/${testId}`);
return data;
}
/** Get detection rules for a specific template. */
export async function getDetectionRulesForTemplate(
templateId: string
): Promise<{ template_id: string; rules: DetectionRuleItem[]; total: number }> {
const { data } = await client.get(`/detection-rules/for-template/${templateId}`);
return data;
}
/** Evaluate a detection rule for a test. */
export async function evaluateDetectionRule(payload: EvaluatePayload): Promise<EvaluateResult> {
const { data } = await client.post<EvaluateResult>("/detection-rules/evaluate", payload);
return data;
}
/** Trigger auto-association of templates ↔ detection rules (admin). */
export async function autoAssociateDetectionRules(): Promise<{
created: number;
skipped: number;
total_associations: number;
}> {
const { data } = await client.post("/detection-rules/auto-associate");
return data;
}
+2 -1
View File
@@ -1,5 +1,5 @@
import client from "./client"; import client from "./client";
import type { Technique, TechniqueStatus, Test, IntelItem } from "../types/models"; import type { Technique, TechniqueStatus, Test, IntelItem, DefensiveTechnique } from "../types/models";
/** Summary representation used in list endpoints. */ /** Summary representation used in list endpoints. */
export interface TechniqueSummary { export interface TechniqueSummary {
@@ -15,6 +15,7 @@ export interface TechniqueSummary {
export interface TechniqueWithTests extends Technique { export interface TechniqueWithTests extends Technique {
tests?: Test[]; tests?: Test[];
intel_items?: IntelItem[]; intel_items?: IntelItem[];
d3fend_defenses?: DefensiveTechnique[];
} }
export interface TechniqueFilters { export interface TechniqueFilters {
@@ -0,0 +1,161 @@
import { useNavigate } from "react-router-dom";
import type { CampaignTest } from "../api/campaigns";
// Kill chain phases in display order
const PHASES = [
{ key: "reconnaissance", label: "Reconnaissance", color: "border-gray-500 bg-gray-500" },
{ key: "resource_development", label: "Resource Dev", color: "border-gray-400 bg-gray-400" },
{ key: "initial_access", label: "Initial Access", color: "border-red-500 bg-red-500" },
{ key: "execution", label: "Execution", color: "border-orange-500 bg-orange-500" },
{ key: "persistence", label: "Persistence", color: "border-amber-500 bg-amber-500" },
{ key: "privilege_escalation", label: "Priv Escalation", color: "border-yellow-500 bg-yellow-500" },
{ key: "defense_evasion", label: "Defense Evasion", color: "border-lime-500 bg-lime-500" },
{ key: "credential_access", label: "Cred Access", color: "border-green-500 bg-green-500" },
{ key: "discovery", label: "Discovery", color: "border-emerald-500 bg-emerald-500" },
{ key: "lateral_movement", label: "Lateral Movement", color: "border-teal-500 bg-teal-500" },
{ key: "collection", label: "Collection", color: "border-cyan-500 bg-cyan-500" },
{ key: "command_and_control", label: "C2", color: "border-blue-500 bg-blue-500" },
{ key: "exfiltration", label: "Exfiltration", color: "border-indigo-500 bg-indigo-500" },
{ key: "impact", label: "Impact", color: "border-purple-500 bg-purple-500" },
];
const stateColors: Record<string, { bg: string; text: string; border: string }> = {
draft: { bg: "bg-gray-800", text: "text-gray-400", border: "border-gray-600" },
red_executing: { bg: "bg-orange-900/50", text: "text-orange-400", border: "border-orange-500/50" },
blue_evaluating: { bg: "bg-indigo-900/50", text: "text-indigo-400", border: "border-indigo-500/50" },
in_review: { bg: "bg-blue-900/50", text: "text-blue-400", border: "border-blue-500/50" },
validated: { bg: "bg-green-900/50", text: "text-green-400", border: "border-green-500/50" },
rejected: { bg: "bg-red-900/50", text: "text-red-400", border: "border-red-500/50" },
};
interface Props {
tests: CampaignTest[];
}
export default function CampaignTimeline({ tests }: Props) {
const navigate = useNavigate();
// Group tests by phase
const testsByPhase: Record<string, CampaignTest[]> = {};
const unphased: CampaignTest[] = [];
for (const t of tests) {
if (t.phase) {
if (!testsByPhase[t.phase]) testsByPhase[t.phase] = [];
testsByPhase[t.phase].push(t);
} else {
unphased.push(t);
}
}
// Filter phases that have tests
const activePhases = PHASES.filter((p) => testsByPhase[p.key]?.length > 0);
if (tests.length === 0) {
return (
<div className="rounded-lg border border-gray-700 bg-gray-800/30 p-8 text-center">
<p className="text-sm text-gray-400">No tests in this campaign yet.</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Horizontal timeline */}
<div className="overflow-x-auto pb-2">
<div className="flex gap-4 min-w-max px-2 py-4">
{activePhases.map((phase, phaseIdx) => {
const phaseTests = testsByPhase[phase.key] || [];
const phaseColor = phase.color.split(" ");
return (
<div key={phase.key} className="flex items-start gap-4">
{/* Phase column */}
<div className="flex flex-col items-center">
{/* Phase label */}
<div className={`mb-2 rounded-full border-2 ${phaseColor[0]} px-3 py-1`}>
<span className="text-[10px] font-semibold text-white whitespace-nowrap">
{phase.label}
</span>
</div>
{/* Phase connector line */}
<div className={`w-0.5 h-2 ${phaseColor[1]}`} />
{/* Tests in this phase */}
<div className="flex flex-col gap-2">
{phaseTests.map((ct) => {
const state = ct.test_state || "draft";
const colors = stateColors[state] || stateColors.draft;
return (
<button
key={ct.id}
onClick={() => navigate(`/tests/${ct.test_id}`)}
className={`group w-48 rounded-lg border ${colors.border} ${colors.bg} p-3 text-left transition-all hover:scale-105 hover:shadow-lg`}
>
<p className={`text-xs font-medium ${colors.text} truncate`}>
{ct.technique_mitre_id && (
<span className="font-mono mr-1">{ct.technique_mitre_id}</span>
)}
</p>
<p className="text-[11px] text-gray-300 truncate mt-0.5">
{ct.test_name || "Unnamed test"}
</p>
<div className="mt-1.5 flex items-center justify-between">
<span className={`text-[10px] font-medium ${colors.text} capitalize`}>
{state.replace(/_/g, " ")}
</span>
{ct.platform && (
<span className="text-[10px] text-gray-500 capitalize">{ct.platform}</span>
)}
</div>
</button>
);
})}
</div>
</div>
{/* Arrow between phases */}
{phaseIdx < activePhases.length - 1 && (
<div className="flex items-center self-center mt-8">
<div className="h-0.5 w-6 bg-gray-700" />
<div className="border-t-4 border-b-4 border-l-6 border-t-transparent border-b-transparent border-l-gray-700" />
</div>
)}
</div>
);
})}
</div>
</div>
{/* Unphased tests */}
{unphased.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-medium uppercase text-gray-500">Unassigned Phase</h4>
<div className="flex flex-wrap gap-2">
{unphased.map((ct) => {
const state = ct.test_state || "draft";
const colors = stateColors[state] || stateColors.draft;
return (
<button
key={ct.id}
onClick={() => navigate(`/tests/${ct.test_id}`)}
className={`rounded-lg border ${colors.border} ${colors.bg} px-3 py-2 text-left transition-all hover:scale-105`}
>
<p className="text-xs text-gray-300 truncate max-w-[200px]">
{ct.test_name || "Unnamed test"}
</p>
<span className={`text-[10px] font-medium ${colors.text} capitalize`}>
{state.replace(/_/g, " ")}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
);
}
+2
View File
@@ -14,6 +14,7 @@ import {
ClipboardList, ClipboardList,
Database, Database,
Crosshair, Crosshair,
Zap,
} from "lucide-react"; } from "lucide-react";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
@@ -39,6 +40,7 @@ const mainLinks: NavItem[] = [
}, },
{ to: "/reports", label: "Reports", icon: BarChart3 }, { to: "/reports", label: "Reports", icon: BarChart3 },
{ to: "/threat-actors", label: "Threat Actors", icon: Crosshair }, { to: "/threat-actors", label: "Threat Actors", icon: Crosshair },
{ to: "/campaigns", label: "Campaigns", icon: Zap },
]; ];
const adminLinks: NavItem[] = [ const adminLinks: NavItem[] = [
@@ -0,0 +1,321 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Loader2,
CheckCircle,
XCircle,
MinusCircle,
ChevronDown,
ChevronRight,
ExternalLink,
Shield,
} from "lucide-react";
import {
getDetectionRulesForTest,
evaluateDetectionRule,
type DetectionRuleItem,
} from "../../api/detection-rules";
import type { User } from "../../types/models";
const severityColors: Record<string, string> = {
critical: "bg-red-900/50 text-red-400 border-red-500/30",
high: "bg-orange-900/50 text-orange-400 border-orange-500/30",
medium: "bg-yellow-900/50 text-yellow-400 border-yellow-500/30",
low: "bg-blue-900/50 text-blue-400 border-blue-500/30",
informational: "bg-gray-800/50 text-gray-400 border-gray-600/30",
};
const sourceColors: Record<string, string> = {
sigma: "bg-purple-900/50 text-purple-400 border-purple-500/30",
elastic: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30",
splunk: "bg-green-900/50 text-green-400 border-green-500/30",
custom: "bg-gray-800/50 text-gray-400 border-gray-600/30",
};
interface Props {
testId: string;
user: User | null;
canEdit: boolean;
}
export default function DetectionRuleChecklist({ testId, user, canEdit }: Props) {
const queryClient = useQueryClient();
const [expandedRules, setExpandedRules] = useState<Set<string>>(new Set());
const [editingNotes, setEditingNotes] = useState<Record<string, string>>({});
const { data, isLoading, error } = useQuery({
queryKey: ["detection-rules-for-test", testId],
queryFn: () => getDetectionRulesForTest(testId),
enabled: !!testId,
});
const evaluateMutation = useMutation({
mutationFn: evaluateDetectionRule,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["detection-rules-for-test", testId] });
},
});
const toggleExpanded = (ruleId: string) => {
setExpandedRules((prev) => {
const next = new Set(prev);
if (next.has(ruleId)) next.delete(ruleId);
else next.add(ruleId);
return next;
});
};
const handleEvaluate = (ruleId: string, triggered: boolean | null) => {
evaluateMutation.mutate({
test_id: testId,
detection_rule_id: ruleId,
triggered,
notes: editingNotes[ruleId],
});
};
const handleNotesChange = (ruleId: string, notes: string) => {
setEditingNotes((prev) => ({ ...prev, [ruleId]: notes }));
};
const handleNotesSave = (ruleId: string, triggered: boolean | null) => {
evaluateMutation.mutate({
test_id: testId,
detection_rule_id: ruleId,
triggered: triggered,
notes: editingNotes[ruleId] ?? "",
});
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-gray-500" />
</div>
);
}
if (error || !data) {
return null;
}
if (data.rules.length === 0) {
return (
<div className="rounded-lg border border-gray-700 bg-gray-800/30 p-4 text-center">
<Shield className="mx-auto h-8 w-8 text-gray-600" />
<p className="mt-2 text-sm text-gray-400">No detection rules available for this technique.</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Summary bar */}
<div className="flex items-center justify-between rounded-lg border border-gray-700 bg-gray-800/50 p-3">
<div className="flex items-center gap-4">
<div className="text-sm text-gray-300">
<span className="font-semibold text-white">{data.triggered}</span>
<span className="text-gray-500"> / </span>
<span className="font-semibold text-white">{data.total}</span>
<span className="ml-1 text-gray-400">rules triggered</span>
</div>
{data.evaluated > 0 && (
<span className="rounded-full bg-cyan-900/50 border border-cyan-500/30 px-2 py-0.5 text-xs font-medium text-cyan-400">
{data.detection_rate}% detection rate
</span>
)}
</div>
<div className="text-xs text-gray-500">
{data.evaluated} / {data.total} evaluated
</div>
</div>
{/* Progress bar */}
<div className="h-2 w-full rounded-full bg-gray-800 overflow-hidden">
<div className="flex h-full">
{data.total > 0 && (
<>
<div
className="bg-green-500 transition-all"
style={{ width: `${(data.triggered / data.total) * 100}%` }}
/>
<div
className="bg-red-500 transition-all"
style={{
width: `${((data.evaluated - data.triggered) / data.total) * 100}%`,
}}
/>
</>
)}
</div>
</div>
{/* Rules list */}
<div className="space-y-2">
{data.rules.map((rule) => {
const isExpanded = expandedRules.has(rule.id);
const notesDraft = editingNotes[rule.id] ?? rule.notes ?? "";
return (
<div
key={rule.id}
className="rounded-lg border border-gray-700 bg-gray-800/30 overflow-hidden"
>
{/* Rule header */}
<div className="flex items-center gap-3 p-3">
{/* Expand toggle */}
<button
onClick={() => toggleExpanded(rule.id)}
className="shrink-0 text-gray-500 hover:text-gray-300"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
{/* Status icon */}
{rule.triggered === true && <CheckCircle className="h-4 w-4 shrink-0 text-green-400" />}
{rule.triggered === false && <XCircle className="h-4 w-4 shrink-0 text-red-400" />}
{rule.triggered == null && <MinusCircle className="h-4 w-4 shrink-0 text-gray-500" />}
{/* Rule info */}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-200 truncate">{rule.title}</p>
</div>
{/* Badges */}
<div className="flex shrink-0 items-center gap-1.5">
{rule.severity && (
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] font-medium ${
severityColors[rule.severity] || severityColors.informational
}`}
>
{rule.severity}
</span>
)}
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] font-medium ${
sourceColors[rule.source] || sourceColors.custom
}`}
>
{rule.source}
</span>
</div>
{/* Evaluate buttons */}
{canEdit && (
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => handleEvaluate(rule.id, true)}
disabled={evaluateMutation.isPending}
className={`rounded p-1 transition-colors ${
rule.triggered === true
? "bg-green-900/50 text-green-400"
: "text-gray-500 hover:bg-green-900/30 hover:text-green-400"
}`}
title="Triggered"
>
<CheckCircle className="h-4 w-4" />
</button>
<button
onClick={() => handleEvaluate(rule.id, false)}
disabled={evaluateMutation.isPending}
className={`rounded p-1 transition-colors ${
rule.triggered === false
? "bg-red-900/50 text-red-400"
: "text-gray-500 hover:bg-red-900/30 hover:text-red-400"
}`}
title="Not Triggered"
>
<XCircle className="h-4 w-4" />
</button>
<button
onClick={() => handleEvaluate(rule.id, null)}
disabled={evaluateMutation.isPending}
className={`rounded p-1 transition-colors ${
rule.triggered === null && rule.result_id
? "bg-gray-700 text-gray-300"
: "text-gray-500 hover:bg-gray-700 hover:text-gray-300"
}`}
title="Not Applicable"
>
<MinusCircle className="h-4 w-4" />
</button>
</div>
)}
</div>
{/* Expanded content */}
{isExpanded && (
<div className="border-t border-gray-700 p-3 space-y-3">
{rule.description && (
<p className="text-xs text-gray-400">{rule.description}</p>
)}
{/* Rule content */}
{rule.rule_content && (
<div>
<p className="mb-1 text-[10px] font-medium uppercase text-gray-500">
Rule Content ({rule.rule_format})
</p>
<pre className="max-h-48 overflow-auto rounded bg-gray-900 p-3 font-mono text-xs text-gray-300">
{rule.rule_content}
</pre>
</div>
)}
{/* Source link */}
{rule.source_url && (
<a
href={rule.source_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-cyan-400 hover:underline"
>
View source
<ExternalLink className="h-3 w-3" />
</a>
)}
{/* Notes */}
{canEdit ? (
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-gray-500">
Notes
</label>
<div className="flex gap-2">
<input
value={notesDraft}
onChange={(e) => handleNotesChange(rule.id, e.target.value)}
placeholder="Add evaluation notes..."
className="flex-1 rounded border border-gray-700 bg-gray-900 px-2 py-1.5 text-xs text-gray-200 placeholder-gray-500 focus:border-indigo-500 focus:outline-none"
/>
<button
onClick={() => handleNotesSave(rule.id, rule.triggered)}
disabled={evaluateMutation.isPending}
className="shrink-0 rounded bg-indigo-600 px-2 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-50"
>
Save
</button>
</div>
</div>
) : (
rule.notes && (
<div>
<p className="text-[10px] font-medium uppercase text-gray-500">Notes</p>
<p className="mt-0.5 text-xs text-gray-400">{rule.notes}</p>
</div>
)
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
@@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { import {
Shield, Shield,
ShieldCheck, ShieldCheck,
@@ -10,6 +11,7 @@ import {
XCircle, XCircle,
AlertTriangle, AlertTriangle,
Trash2, Trash2,
ExternalLink,
} from "lucide-react"; } from "lucide-react";
import type { import type {
Test, Test,
@@ -18,8 +20,11 @@ import type {
Evidence, Evidence,
TestTimelineEntry, TestTimelineEntry,
User, User,
DefensiveTechnique,
} from "../../types/models"; } from "../../types/models";
import { RED_EDITABLE_STATES, BLUE_EDITABLE_STATES } from "../../types/models"; import { RED_EDITABLE_STATES, BLUE_EDITABLE_STATES } from "../../types/models";
import { getDefensesForTechnique } from "../../api/d3fend";
import DetectionRuleChecklist from "./DetectionRuleChecklist";
import EvidenceUpload from "../EvidenceUpload"; import EvidenceUpload from "../EvidenceUpload";
import EvidenceList from "../EvidenceList"; import EvidenceList from "../EvidenceList";
@@ -105,6 +110,13 @@ export default function TeamTabs({
const [activeTab, setActiveTab] = useState<TabKey>("red"); const [activeTab, setActiveTab] = useState<TabKey>("red");
const role = user?.role ?? ""; const role = user?.role ?? "";
// Fetch D3FEND defenses for the test's technique
const { data: d3fendData } = useQuery({
queryKey: ["d3fend-defenses", test.technique_mitre_id],
queryFn: () => getDefensesForTechnique(test.technique_mitre_id!),
enabled: !!test.technique_mitre_id,
});
const canEditRed = const canEditRed =
RED_EDITABLE_STATES.includes(test.state) && RED_EDITABLE_STATES.includes(test.state) &&
(role === "red_tech" || role === "admin"); (role === "red_tech" || role === "admin");
@@ -326,6 +338,68 @@ export default function TeamTabs({
/> />
</div> </div>
{/* Detection Rule Checklist */}
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-gray-300">
<ShieldCheck className="h-4 w-4 text-indigo-400" />
Detection Rule Evaluation
</h3>
<DetectionRuleChecklist
testId={test.id}
user={user}
canEdit={canEditBlue}
/>
</div>
{/* Recommended Detection Approaches (D3FEND) */}
{d3fendData && d3fendData.defenses.length > 0 && (
<div className="rounded-lg border border-emerald-500/20 bg-emerald-900/10 p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-emerald-400">
<Shield className="h-4 w-4" />
Recommended Detection Approaches
<span className="ml-auto rounded-full bg-emerald-900/50 border border-emerald-500/30 px-2 py-0.5 text-[10px] font-medium text-emerald-400">
{d3fendData.defenses.length} countermeasure{d3fendData.defenses.length !== 1 ? "s" : ""}
</span>
</h3>
<div className="space-y-2 max-h-64 overflow-y-auto pr-1">
{d3fendData.defenses.map((def) => (
<div
key={def.id}
className="flex items-start justify-between rounded-lg border border-gray-700 bg-gray-800/50 p-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="shrink-0 rounded bg-emerald-900/50 border border-emerald-500/30 px-1.5 py-0.5 font-mono text-[10px] text-emerald-400">
{def.d3fend_id}
</span>
<span className="text-sm font-medium text-gray-200">{def.name}</span>
{def.tactic && (
<span className="shrink-0 rounded-full bg-gray-800 border border-gray-700 px-1.5 py-0.5 text-[10px] text-gray-400">
{def.tactic}
</span>
)}
</div>
{def.description && (
<p className="mt-1 text-xs text-gray-400 line-clamp-2">{def.description}</p>
)}
</div>
{def.d3fend_url && (
<a
href={def.d3fend_url}
target="_blank"
rel="noopener noreferrer"
className="ml-2 shrink-0 text-gray-500 hover:text-cyan-400"
title="View in D3FEND"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
)}
</div>
))}
</div>
</div>
)}
{/* Blue validation status if applicable */} {/* Blue validation status if applicable */}
{test.blue_validation_status && ( {test.blue_validation_status && (
<div <div
+416
View File
@@ -0,0 +1,416 @@
import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Loader2,
AlertCircle,
ArrowLeft,
Play,
CheckCircle,
Target,
Plus,
Trash2,
Zap,
Calendar,
Clock,
} from "lucide-react";
import {
getCampaign,
activateCampaign,
completeCampaign,
removeTestFromCampaign,
type Campaign,
} from "../api/campaigns";
import { useAuth } from "../context/AuthContext";
import CampaignTimeline from "../components/CampaignTimeline";
const statusColors: Record<string, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
active: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30",
completed: "bg-green-900/50 text-green-400 border-green-500/30",
archived: "bg-gray-800/50 text-gray-500 border-gray-700/30",
};
const typeLabels: Record<string, string> = {
custom: "Custom",
apt_emulation: "APT Emulation",
kill_chain: "Kill Chain",
compliance: "Compliance",
};
const testStateColors: Record<string, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
validated: "bg-green-900/50 text-green-400 border-green-500/30",
rejected: "bg-red-900/50 text-red-400 border-red-500/30",
};
export default function CampaignDetailPage() {
const { campaignId } = useParams<{ campaignId: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user } = useAuth();
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const showToast = (message: string, type: "success" | "error") => {
setToast({ message, type });
setTimeout(() => setToast(null), 5000);
};
const role = user?.role ?? "";
const canManage = role === "admin" || role === "red_tech";
const canComplete = role === "admin" || role === "red_lead";
const {
data: campaign,
isLoading,
error,
} = useQuery({
queryKey: ["campaign", campaignId],
queryFn: () => getCampaign(campaignId!),
enabled: !!campaignId,
});
const activateMutation = useMutation({
mutationFn: () => activateCampaign(campaignId!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
showToast("Campaign activated", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
});
const completeMutation = useMutation({
mutationFn: () => completeCampaign(campaignId!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
showToast("Campaign completed", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
});
const removeMutation = useMutation({
mutationFn: (campaignTestId: string) => removeTestFromCampaign(campaignId!, campaignTestId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
showToast("Test removed from campaign", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
});
const formatDate = (dateStr: string | null) => {
if (!dateStr) return "\u2014";
return new Date(dateStr).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
);
}
if (error || !campaign) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<AlertCircle className="h-10 w-10 text-red-400" />
<p className="text-red-400">Failed to load campaign</p>
<button
onClick={() => navigate("/campaigns")}
className="mt-2 flex items-center gap-1 text-sm text-cyan-400 hover:underline"
>
<ArrowLeft className="h-4 w-4" />
Back to campaigns
</button>
</div>
);
}
const progress = campaign.progress;
return (
<div className="space-y-6">
{/* Back button */}
<button
onClick={() => navigate("/campaigns")}
className="flex items-center gap-1 text-sm text-gray-400 hover:text-cyan-400 transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back to campaigns
</button>
{/* Header */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-cyan-500/10 p-3">
<Zap className="h-8 w-8 text-cyan-400" />
</div>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-white">{campaign.name}</h1>
<span
className={`inline-flex rounded-full border px-2.5 py-0.5 text-xs font-medium ${
statusColors[campaign.status] || statusColors.draft
}`}
>
{campaign.status}
</span>
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-800/50 px-2.5 py-0.5 text-xs font-medium text-gray-400">
{typeLabels[campaign.type] || campaign.type}
</span>
</div>
{campaign.description && (
<p className="mt-1 text-sm text-gray-400">{campaign.description}</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-gray-500">
{campaign.threat_actor_name && (
<button
onClick={() => navigate(`/threat-actors/${campaign.threat_actor_id}`)}
className="flex items-center gap-1 text-red-400 hover:underline"
>
<Target className="h-3.5 w-3.5" />
{campaign.threat_actor_name}
</button>
)}
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
Created {formatDate(campaign.created_at)}
</span>
{campaign.completed_at && (
<span className="flex items-center gap-1 text-green-400">
<CheckCircle className="h-3.5 w-3.5" />
Completed {formatDate(campaign.completed_at)}
</span>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{canManage && campaign.status === "draft" && (
<button
onClick={() => activateMutation.mutate()}
disabled={activateMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
>
{activateMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
Activate
</button>
)}
{canComplete && campaign.status === "active" && (
<button
onClick={() => completeMutation.mutate()}
disabled={completeMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-500 disabled:opacity-50 transition-colors"
>
{completeMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle className="h-4 w-4" />
)}
Mark Completed
</button>
)}
</div>
</div>
</div>
{/* Progress Panel */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-lg font-semibold text-white">Progress</h2>
<div className="flex items-center gap-6 mb-4">
<div>
<span className="text-3xl font-bold text-white">{progress.completion_pct}%</span>
<span className="ml-1 text-sm text-gray-400">complete</span>
</div>
<div className="flex-1">
<div className="h-3 w-full rounded-full bg-gray-800 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
progress.completion_pct === 100 ? "bg-green-500" : "bg-cyan-500"
}`}
style={{ width: `${progress.completion_pct}%` }}
/>
</div>
</div>
<span className="text-sm text-gray-400">
{progress.by_state?.validated || 0} / {progress.total} tests
</span>
</div>
{/* State breakdown */}
{progress.total > 0 && (
<div className="flex flex-wrap gap-3">
{Object.entries(progress.by_state || {}).map(([state, count]) => (
<div
key={state}
className={`rounded-lg border px-3 py-1.5 text-xs font-medium ${
testStateColors[state] || testStateColors.draft
}`}
>
{state.replace(/_/g, " ")}: {count}
</div>
))}
</div>
)}
</div>
{/* Kill Chain Timeline */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Kill Chain Timeline</h2>
{campaign.tags && campaign.tags.length > 0 && (
<div className="flex gap-1">
{campaign.tags.map((tag, i) => (
<span
key={i}
className="rounded-full bg-gray-800 border border-gray-700 px-2 py-0.5 text-[10px] text-gray-400"
>
{tag}
</span>
))}
</div>
)}
</div>
<CampaignTimeline tests={campaign.tests} />
</div>
{/* Tests Table */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">
Tests ({campaign.tests.length})
</h2>
{canManage && campaign.status === "draft" && (
<button
onClick={() => navigate(`/tests?campaign=${campaignId}`)}
className="flex items-center gap-1.5 rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-3 py-1.5 text-sm font-medium text-cyan-400 hover:bg-cyan-500/20 transition-colors"
>
<Plus className="h-4 w-4" />
Add Test
</button>
)}
</div>
{campaign.tests.length > 0 ? (
<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">#</th>
<th className="pb-3 px-4 font-medium text-gray-400">Technique</th>
<th className="pb-3 px-4 font-medium text-gray-400">Test Name</th>
<th className="pb-3 px-4 font-medium text-gray-400">Phase</th>
<th className="pb-3 px-4 font-medium text-gray-400">State</th>
<th className="pb-3 px-4 font-medium text-gray-400">Platform</th>
<th className="pb-3 pl-4 font-medium text-gray-400">Actions</th>
</tr>
</thead>
<tbody>
{campaign.tests
.sort((a, b) => a.order_index - b.order_index)
.map((ct) => (
<tr
key={ct.id}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
>
<td className="py-3 pr-4">
<span className="text-xs text-gray-500">{ct.order_index + 1}</span>
</td>
<td className="py-3 px-4">
<span className="font-mono text-xs text-cyan-400">
{ct.technique_mitre_id || "\u2014"}
</span>
</td>
<td className="py-3 px-4">
<button
onClick={() => navigate(`/tests/${ct.test_id}`)}
className="text-sm font-medium text-gray-200 hover:text-cyan-400 transition-colors"
>
{ct.test_name || "Unnamed test"}
</button>
</td>
<td className="py-3 px-4">
<span className="text-xs text-gray-400 capitalize">
{ct.phase?.replace(/_/g, " ") || "\u2014"}
</span>
</td>
<td className="py-3 px-4">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
testStateColors[ct.test_state || "draft"] || testStateColors.draft
}`}
>
{(ct.test_state || "draft").replace(/_/g, " ")}
</span>
</td>
<td className="py-3 px-4">
<span className="text-xs text-gray-400 capitalize">
{ct.platform || "\u2014"}
</span>
</td>
<td className="py-3 pl-4">
<div className="flex items-center gap-2">
<button
onClick={() => navigate(`/tests/${ct.test_id}`)}
className="rounded p-1 text-gray-400 hover:bg-gray-800 hover:text-cyan-400"
title="View Test"
>
<Clock className="h-4 w-4" />
</button>
{canManage && (campaign.status === "draft" || campaign.status === "active") && (
<button
onClick={() => removeMutation.mutate(ct.id)}
disabled={removeMutation.isPending}
className="rounded p-1 text-gray-400 hover:bg-red-900/50 hover:text-red-400"
title="Remove from campaign"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-gray-400">
<Zap className="mb-2 h-8 w-8 text-gray-600" />
<p>No tests in this campaign yet.</p>
</div>
)}
</div>
{/* Toast notification */}
{toast && (
<div
className={`fixed bottom-6 right-6 z-50 rounded-lg border px-4 py-3 text-sm shadow-lg backdrop-blur transition-all ${
toast.type === "success"
? "border-green-500/30 bg-green-900/90 text-green-300"
: "border-red-500/30 bg-red-900/90 text-red-300"
}`}
>
{toast.message}
</div>
)}
</div>
);
}
+349
View File
@@ -0,0 +1,349 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Loader2,
AlertCircle,
Plus,
Search,
Crosshair,
Zap,
Filter,
Target,
} from "lucide-react";
import { listCampaigns, createCampaign, type CampaignSummary } from "../api/campaigns";
import { useAuth } from "../context/AuthContext";
const statusColors: Record<string, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
active: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30",
completed: "bg-green-900/50 text-green-400 border-green-500/30",
archived: "bg-gray-800/50 text-gray-500 border-gray-700/30",
};
const typeColors: Record<string, string> = {
custom: "bg-gray-800/50 text-gray-400 border-gray-600/30",
apt_emulation: "bg-red-900/50 text-red-400 border-red-500/30",
kill_chain: "bg-orange-900/50 text-orange-400 border-orange-500/30",
compliance: "bg-blue-900/50 text-blue-400 border-blue-500/30",
};
const typeLabels: Record<string, string> = {
custom: "Custom",
apt_emulation: "APT Emulation",
kill_chain: "Kill Chain",
compliance: "Compliance",
};
export default function CampaignsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user } = useAuth();
const [filters, setFilters] = useState({
type: "",
status: "",
search: "",
});
const [showCreateForm, setShowCreateForm] = useState(false);
const [newCampaign, setNewCampaign] = useState({
name: "",
description: "",
type: "custom",
target_platform: "",
});
const canCreate = user?.role === "admin" || user?.role === "red_tech";
const { data, isLoading, error } = useQuery({
queryKey: ["campaigns", filters],
queryFn: () =>
listCampaigns({
type: filters.type || undefined,
status: filters.status || undefined,
search: filters.search || undefined,
}),
});
const createMutation = useMutation({
mutationFn: () => createCampaign(newCampaign),
onSuccess: (campaign) => {
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
setShowCreateForm(false);
setNewCampaign({ name: "", description: "", type: "custom", target_platform: "" });
navigate(`/campaigns/${campaign.id}`);
},
});
const formatDate = (dateStr: string | null) => {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Campaigns</h1>
<p className="mt-1 text-sm text-gray-400">
Manage attack chain campaigns and APT emulations
</p>
</div>
<div className="flex items-center gap-3">
{canCreate && (
<>
<button
onClick={() => navigate("/threat-actors")}
className="flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-500/20 transition-colors"
>
<Crosshair className="h-4 w-4" />
Generate from Threat Actor
</button>
<button
onClick={() => setShowCreateForm(true)}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 transition-colors"
>
<Plus className="h-4 w-4" />
New Campaign
</button>
</>
)}
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
<input
value={filters.search}
onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value }))}
placeholder="Search campaigns..."
className="w-full rounded-lg border border-gray-700 bg-gray-800 pl-10 pr-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
/>
</div>
<select
value={filters.type}
onChange={(e) => setFilters((f) => ({ ...f, type: e.target.value }))}
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 Types</option>
<option value="custom">Custom</option>
<option value="apt_emulation">APT Emulation</option>
<option value="kill_chain">Kill Chain</option>
<option value="compliance">Compliance</option>
</select>
<select
value={filters.status}
onChange={(e) => setFilters((f) => ({ ...f, status: e.target.value }))}
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 Statuses</option>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
<option value="archived">Archived</option>
</select>
</div>
{/* Create Form Modal */}
{showCreateForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-lg rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-lg font-semibold text-white">Create Campaign</h2>
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-300">Name</label>
<input
value={newCampaign.name}
onChange={(e) => setNewCampaign((c) => ({ ...c, name: e.target.value }))}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
placeholder="Campaign name..."
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-300">Description</label>
<textarea
value={newCampaign.description}
onChange={(e) => setNewCampaign((c) => ({ ...c, description: e.target.value }))}
rows={3}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
placeholder="Describe the campaign objective..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-300">Type</label>
<select
value={newCampaign.type}
onChange={(e) => setNewCampaign((c) => ({ ...c, type: e.target.value }))}
className="w-full 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="custom">Custom</option>
<option value="kill_chain">Kill Chain</option>
<option value="compliance">Compliance</option>
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-300">Platform</label>
<select
value={newCampaign.target_platform}
onChange={(e) => setNewCampaign((c) => ({ ...c, target_platform: e.target.value }))}
className="w-full 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="">Any</option>
<option value="windows">Windows</option>
<option value="linux">Linux</option>
<option value="macos">macOS</option>
</select>
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setShowCreateForm(false)}
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
>
Cancel
</button>
<button
onClick={() => createMutation.mutate()}
disabled={!newCampaign.name || createMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
>
{createMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Create Campaign
</button>
</div>
</div>
</div>
)}
{/* Campaign grid */}
{isLoading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
</div>
) : error ? (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<AlertCircle className="h-10 w-10 text-red-400" />
<p className="text-red-400">Failed to load campaigns</p>
</div>
) : data && data.items.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{data.items.map((campaign) => (
<button
key={campaign.id}
onClick={() => navigate(`/campaigns/${campaign.id}`)}
className="group rounded-xl border border-gray-800 bg-gray-900 p-5 text-left transition-all hover:border-gray-700 hover:shadow-lg"
>
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] font-medium ${
typeColors[campaign.type] || typeColors.custom
}`}
>
{typeLabels[campaign.type] || campaign.type}
</span>
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] font-medium ${
statusColors[campaign.status] || statusColors.draft
}`}
>
{campaign.status}
</span>
</div>
<Zap className="h-4 w-4 text-gray-600 group-hover:text-cyan-400 transition-colors" />
</div>
{/* Name & Description */}
<h3 className="text-sm font-semibold text-white group-hover:text-cyan-300 transition-colors truncate">
{campaign.name}
</h3>
{campaign.description && (
<p className="mt-1 text-xs text-gray-400 line-clamp-2">
{campaign.description}
</p>
)}
{/* Threat Actor */}
{campaign.threat_actor_name && (
<div className="mt-2 flex items-center gap-1.5">
<Target className="h-3.5 w-3.5 text-red-400" />
<span className="text-xs text-red-400">{campaign.threat_actor_name}</span>
</div>
)}
{/* Progress bar */}
<div className="mt-3">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-gray-500">
{campaign.test_count} test{campaign.test_count !== 1 ? "s" : ""}
</span>
<span className="text-[10px] font-medium text-gray-400">
{campaign.completion_pct}%
</span>
</div>
<div className="h-1.5 w-full rounded-full bg-gray-800 overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
campaign.completion_pct === 100
? "bg-green-500"
: campaign.completion_pct > 0
? "bg-cyan-500"
: "bg-gray-700"
}`}
style={{ width: `${campaign.completion_pct}%` }}
/>
</div>
</div>
{/* Tags */}
{campaign.tags && campaign.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{campaign.tags.slice(0, 3).map((tag, i) => (
<span
key={i}
className="rounded-full bg-gray-800 border border-gray-700 px-1.5 py-0.5 text-[10px] text-gray-400"
>
{tag}
</span>
))}
{campaign.tags.length > 3 && (
<span className="text-[10px] text-gray-500">+{campaign.tags.length - 3}</span>
)}
</div>
)}
{/* Date */}
<p className="mt-2 text-[10px] text-gray-600">{formatDate(campaign.created_at)}</p>
</button>
))}
</div>
) : (
<div className="flex h-64 flex-col items-center justify-center gap-2">
<Zap className="h-10 w-10 text-gray-600" />
<p className="text-gray-400">No campaigns found</p>
{canCreate && (
<button
onClick={() => setShowCreateForm(true)}
className="mt-2 flex items-center gap-1 text-sm text-cyan-400 hover:underline"
>
<Plus className="h-4 w-4" />
Create your first campaign
</button>
)}
</div>
)}
</div>
);
}
@@ -408,6 +408,80 @@ export default function TechniqueDetailPage() {
)} )}
</div> </div>
{/* Recommended Defenses (D3FEND) */}
{technique.d3fend_defenses && technique.d3fend_defenses.length > 0 && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Shield className="h-5 w-5 text-emerald-400" />
Recommended Defenses (D3FEND)
</h2>
<span className="rounded-full bg-emerald-900/50 border border-emerald-500/30 px-2.5 py-0.5 text-xs font-medium text-emerald-400">
{technique.d3fend_defenses.length} countermeasure{technique.d3fend_defenses.length !== 1 ? "s" : ""}
</span>
</div>
{/* Group by tactic */}
{(() => {
const grouped: Record<string, typeof technique.d3fend_defenses> = {};
for (const def of technique.d3fend_defenses!) {
const tactic = def.tactic || "Other";
if (!grouped[tactic]) grouped[tactic] = [];
grouped[tactic].push(def);
}
const tacticColors: Record<string, string> = {
Detect: "border-blue-500/30 bg-blue-900/20 text-blue-400",
Harden: "border-emerald-500/30 bg-emerald-900/20 text-emerald-400",
Isolate: "border-purple-500/30 bg-purple-900/20 text-purple-400",
Deceive: "border-amber-500/30 bg-amber-900/20 text-amber-400",
Evict: "border-red-500/30 bg-red-900/20 text-red-400",
Model: "border-cyan-500/30 bg-cyan-900/20 text-cyan-400",
};
return Object.entries(grouped).map(([tactic, defenses]) => (
<div key={tactic} className="mb-4 last:mb-0">
<h3 className="mb-2 text-sm font-medium text-gray-400 uppercase tracking-wide">
{tactic}
</h3>
<div className="grid gap-2 sm:grid-cols-2">
{defenses!.map((def) => (
<div
key={def.id}
className={`rounded-lg border p-3 transition-colors hover:border-gray-600 ${
tacticColors[tactic] || "border-gray-700 bg-gray-800/30 text-gray-300"
}`}
>
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-200">
<span className="font-mono text-xs text-gray-500 mr-1.5">{def.d3fend_id}</span>
{def.name}
</p>
{def.description && (
<p className="mt-1 text-xs text-gray-400 line-clamp-2">{def.description}</p>
)}
</div>
{def.d3fend_url && (
<a
href={def.d3fend_url}
target="_blank"
rel="noopener noreferrer"
className="ml-2 shrink-0 text-gray-500 hover:text-cyan-400"
title="View in D3FEND"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
)}
</div>
</div>
))}
</div>
</div>
));
})()}
</div>
)}
{/* Intel Items Section */} {/* Intel Items Section */}
{technique.intel_items && technique.intel_items.length > 0 && ( {technique.intel_items && technique.intel_items.length > 0 && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6"> <div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
+11
View File
@@ -187,3 +187,14 @@ export interface TacticCoverage {
not_evaluated: number; not_evaluated: number;
in_progress: number; in_progress: number;
} }
// ── D3FEND ────────────────────────────────────────────────────────
export interface DefensiveTechnique {
id: string;
d3fend_id: string;
name: string;
description: string | null;
tactic: string | null;
d3fend_url: string | null;
}