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