feat(phase-26): add Campaign models, endpoints, service with kill chain timeline UI (T-217 to T-220)

This commit is contained in:
2026-02-09 16:52:52 +01:00
parent f4de12d8ab
commit 57b47c296d
12 changed files with 2032 additions and 0 deletions

View File

@@ -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')

View File

@@ -22,6 +22,7 @@ from app.routers import data_sources as data_sources_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.jobs.mitre_sync_job import start_scheduler, scheduler
@@ -68,6 +69,7 @@ app.include_router(data_sources_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")

View File

@@ -13,6 +13,7 @@ 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
__all__ = [
@@ -21,5 +22,6 @@ __all__ = [
"DetectionRule", "ThreatActor", "ThreatActorTechnique",
"DefensiveTechnique", "DefensiveTechniqueMapping",
"TestTemplateDetectionRule", "TestDetectionResult",
"Campaign", "CampaignTest",
"TechniqueStatus", "TestState", "TestResult", "TeamSide",
]

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'),
)

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)

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

View File

@@ -14,6 +14,8 @@ import AuditLogPage from "./pages/AuditLogPage";
import DataSourcesPage from "./pages/DataSourcesPage";
import ThreatActorsPage from "./pages/ThreatActorsPage";
import ThreatActorDetailPage from "./pages/ThreatActorDetailPage";
import CampaignsPage from "./pages/CampaignsPage";
import CampaignDetailPage from "./pages/CampaignDetailPage";
import Layout from "./components/Layout";
import ProtectedRoute from "./components/ProtectedRoute";
@@ -42,6 +44,8 @@ export default function App() {
<Route path="/reports" element={<ReportsPage />} />
<Route path="/threat-actors" element={<ThreatActorsPage />} />
<Route path="/threat-actors/:actorId" element={<ThreatActorDetailPage />} />
<Route path="/campaigns" element={<CampaignsPage />} />
<Route path="/campaigns/:campaignId" element={<CampaignDetailPage />} />
<Route
path="/system"
element={

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

View File

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

View File

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

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

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