feat(phase-26): add Campaign models, endpoints, service with kill chain timeline UI (T-217 to T-220)
This commit is contained in:
74
backend/alembic/versions/b013_add_campaigns_tables.py
Normal file
74
backend/alembic/versions/b013_add_campaigns_tables.py
Normal 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')
|
||||||
@@ -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 threat_actors as threat_actors_router
|
||||||
from app.routers import d3fend as d3fend_router
|
from app.routers import d3fend as d3fend_router
|
||||||
from app.routers import detection_rules as detection_rules_router
|
from app.routers import detection_rules as detection_rules_router
|
||||||
|
from app.routers import campaigns as campaigns_router
|
||||||
from app.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
|
||||||
|
|
||||||
@@ -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(threat_actors_router.router, prefix="/api/v1")
|
||||||
app.include_router(d3fend_router.router, prefix="/api/v1")
|
app.include_router(d3fend_router.router, prefix="/api/v1")
|
||||||
app.include_router(detection_rules_router.router, prefix="/api/v1")
|
app.include_router(detection_rules_router.router, prefix="/api/v1")
|
||||||
|
app.include_router(campaigns_router.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.models.threat_actor import ThreatActor, ThreatActorTechnique
|
|||||||
from app.models.defensive_technique import DefensiveTechnique, DefensiveTechniqueMapping
|
from app.models.defensive_technique import DefensiveTechnique, DefensiveTechniqueMapping
|
||||||
from app.models.test_template_detection_rule import TestTemplateDetectionRule
|
from app.models.test_template_detection_rule import TestTemplateDetectionRule
|
||||||
from app.models.test_detection_result import TestDetectionResult
|
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__ = [
|
||||||
@@ -21,5 +22,6 @@ __all__ = [
|
|||||||
"DetectionRule", "ThreatActor", "ThreatActorTechnique",
|
"DetectionRule", "ThreatActor", "ThreatActorTechnique",
|
||||||
"DefensiveTechnique", "DefensiveTechniqueMapping",
|
"DefensiveTechnique", "DefensiveTechniqueMapping",
|
||||||
"TestTemplateDetectionRule", "TestDetectionResult",
|
"TestTemplateDetectionRule", "TestDetectionResult",
|
||||||
|
"Campaign", "CampaignTest",
|
||||||
"TechniqueStatus", "TestState", "TestResult", "TeamSide",
|
"TechniqueStatus", "TestState", "TestResult", "TeamSide",
|
||||||
]
|
]
|
||||||
|
|||||||
132
backend/app/models/campaign.py
Normal file
132
backend/app/models/campaign.py
Normal 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'),
|
||||||
|
)
|
||||||
524
backend/app/routers/campaigns.py
Normal file
524
backend/app/routers/campaigns.py
Normal 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)
|
||||||
213
backend/app/services/campaign_service.py
Normal file
213
backend/app/services/campaign_service.py
Normal 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
|
||||||
@@ -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
frontend/src/api/campaigns.ts
Normal file
153
frontend/src/api/campaigns.ts
Normal 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;
|
||||||
|
}
|
||||||
161
frontend/src/components/CampaignTimeline.tsx
Normal file
161
frontend/src/components/CampaignTimeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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[] = [
|
||||||
|
|||||||
416
frontend/src/pages/CampaignDetailPage.tsx
Normal file
416
frontend/src/pages/CampaignDetailPage.tsx
Normal 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
frontend/src/pages/CampaignsPage.tsx
Normal file
349
frontend/src/pages/CampaignsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user