feat(attack-paths): Phase 10 — Attack Paths & Advanced Purple Team [FASE-10]
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Models (5 tables):
- AttackPath: named reusable attack scenario with template flag
- AttackPathStep: ordered kill-chain step (technique + test link)
- AttackPathExecution: a run with Red/Blue leads, timing, stored metrics
- AttackPathStepResult: per-step detected/not_detected/skipped result
- TimelineEntry: timestamped Red/Blue/system actions for MTTD/MTTR
Migration b036atk: raw SQL to avoid SQLAlchemy DDL hook issues
Service (attack_path_service.py):
- Full CRUD for paths + steps (add, update, delete, reorder)
- Execution lifecycle: create → start → execute steps → complete/abort
- Pre-creates pending step results on execution creation
- Auto-adds system timeline entries on key state transitions
- complete_execution() computes: detection_rate, mttd_seconds,
furthest_undetected_step, detected/not_detected/skipped counts
- get_kill_chain_metrics(): per-step breakdown + phase summary
Router /api/v1/attack-paths (20 endpoints):
POST/GET/PATCH/DELETE attack paths
GET/POST/PATCH/DELETE steps + reorder
POST/GET executions per path
GET/POST/start/complete/abort executions
POST/GET step results
POST/GET timeline entries
GET kill-chain metrics
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,7 @@ from app.routers import osint as osint_router
|
||||
from app.routers import webhooks as webhooks_router
|
||||
from app.routers import detection_lifecycle as detection_lifecycle_router
|
||||
from app.routers import ownership as ownership_router
|
||||
from app.routers import attack_paths as attack_paths_router
|
||||
from app.domain.errors import DomainError
|
||||
from app.middleware.error_handler import domain_exception_handler
|
||||
from app.middleware.request_context import RequestContextMiddleware
|
||||
@@ -139,6 +140,7 @@ app.include_router(osint_router.router, prefix="/api/v1")
|
||||
app.include_router(webhooks_router.router, prefix="/api/v1")
|
||||
app.include_router(detection_lifecycle_router.router, prefix="/api/v1")
|
||||
app.include_router(ownership_router.router, prefix="/api/v1")
|
||||
app.include_router(attack_paths_router.router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/health", include_in_schema=False)
|
||||
|
||||
@@ -33,6 +33,11 @@ from app.models.ownership_queue import (
|
||||
TechniqueOwnership, RevalidationQueueItem,
|
||||
QueuePriority, QueueStatus, QueueReason,
|
||||
)
|
||||
from app.models.attack_path import (
|
||||
AttackPath, AttackPathStep, AttackPathExecution,
|
||||
AttackPathStepResult, TimelineEntry,
|
||||
ExecutionStatus, StepResultStatus, TimelineActorSide, TimelineEntryType,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"User", "Technique", "Test", "TestTemplate", "Evidence",
|
||||
@@ -51,4 +56,7 @@ __all__ = [
|
||||
"TechniqueConfidenceScore", "InfrastructureChangeLog", "DecayPolicy",
|
||||
"TechniqueOwnership", "RevalidationQueueItem",
|
||||
"QueuePriority", "QueueStatus", "QueueReason",
|
||||
"AttackPath", "AttackPathStep", "AttackPathExecution",
|
||||
"AttackPathStepResult", "TimelineEntry",
|
||||
"ExecutionStatus", "StepResultStatus", "TimelineActorSide", "TimelineEntryType",
|
||||
]
|
||||
|
||||
253
backend/app/models/attack_path.py
Normal file
253
backend/app/models/attack_path.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""Phase 10: Attack Paths & Advanced Purple Team models."""
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean, Column, DateTime, Enum, Float, ForeignKey,
|
||||
Index, Integer, String, Text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ExecutionStatus(str, enum.Enum):
|
||||
planned = "planned"
|
||||
in_progress = "in_progress"
|
||||
completed = "completed"
|
||||
aborted = "aborted"
|
||||
|
||||
|
||||
class StepResultStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
executing = "executing"
|
||||
detected = "detected"
|
||||
not_detected = "not_detected"
|
||||
skipped = "skipped"
|
||||
|
||||
|
||||
class TimelineActorSide(str, enum.Enum):
|
||||
red = "red"
|
||||
blue = "blue"
|
||||
system = "system"
|
||||
|
||||
|
||||
class TimelineEntryType(str, enum.Enum):
|
||||
action = "action"
|
||||
detection = "detection"
|
||||
note = "note"
|
||||
phase_transition = "phase_transition"
|
||||
flag = "flag"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AttackPath(Base):
|
||||
"""
|
||||
A reusable attack scenario composed of ordered kill-chain steps.
|
||||
Can be a template (shared) or a one-off scenario.
|
||||
"""
|
||||
__tablename__ = "attack_paths"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String(300), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
objective = Column(Text, nullable=True) # what the attacker aims to achieve
|
||||
is_template = Column(Boolean, default=False) # reusable template flag
|
||||
threat_actor_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("threat_actors.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_by = Column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
tags = Column(JSONB, nullable=True, default=list)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
steps = relationship(
|
||||
"AttackPathStep", back_populates="attack_path",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="AttackPathStep.order_index",
|
||||
)
|
||||
executions = relationship("AttackPathExecution", back_populates="attack_path")
|
||||
creator = relationship("User", foreign_keys=[created_by])
|
||||
threat_actor = relationship("ThreatActor", foreign_keys=[threat_actor_id])
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_attack_paths_created_by", "created_by"),
|
||||
Index("ix_attack_paths_is_template", "is_template"),
|
||||
)
|
||||
|
||||
|
||||
class AttackPathStep(Base):
|
||||
"""One step in an attack path — maps to a kill-chain phase + technique."""
|
||||
|
||||
__tablename__ = "attack_path_steps"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
attack_path_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("attack_paths.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
order_index = Column(Integer, nullable=False, default=0)
|
||||
kill_chain_phase = Column(String(60), nullable=True) # initial_access, execution, …
|
||||
technique_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("techniques.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
test_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("tests.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
name = Column(String(300), nullable=True) # human label for the step
|
||||
description = Column(Text, nullable=True)
|
||||
expected_detection = Column(Boolean, default=True) # do we expect blue to detect this?
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
attack_path = relationship("AttackPath", back_populates="steps")
|
||||
technique = relationship("Technique", foreign_keys=[technique_id])
|
||||
test = relationship("Test", foreign_keys=[test_id])
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_ap_steps_path_id", "attack_path_id"),
|
||||
Index("ix_ap_steps_technique_id", "technique_id"),
|
||||
)
|
||||
|
||||
|
||||
class AttackPathExecution(Base):
|
||||
"""
|
||||
A single run of an attack path.
|
||||
Tracks Red/Blue participants, timing, and aggregated kill-chain metrics.
|
||||
"""
|
||||
__tablename__ = "attack_path_executions"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
attack_path_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("attack_paths.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
status = Column(
|
||||
Enum(ExecutionStatus, name="execution_status"), nullable=False,
|
||||
default=ExecutionStatus.planned,
|
||||
)
|
||||
environment = Column(String(100), nullable=True) # prod, staging, lab
|
||||
red_team_lead = Column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
blue_team_lead = Column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
started_by = Column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# ── Computed kill-chain metrics (written on complete) ─────────────────
|
||||
total_steps = Column(Integer, nullable=True)
|
||||
detected_steps = Column(Integer, nullable=True)
|
||||
not_detected_steps = Column(Integer, nullable=True)
|
||||
skipped_steps = Column(Integer, nullable=True)
|
||||
detection_rate = Column(Float, nullable=True) # 0.0–1.0
|
||||
mttd_seconds = Column(Float, nullable=True) # mean time to detect (avg across detected)
|
||||
furthest_undetected_step = Column(Integer, nullable=True) # order_index of deepest undetected step
|
||||
|
||||
attack_path = relationship("AttackPath", back_populates="executions")
|
||||
step_results = relationship(
|
||||
"AttackPathStepResult", back_populates="execution",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="AttackPathStepResult.step_order",
|
||||
)
|
||||
timeline = relationship(
|
||||
"TimelineEntry", back_populates="execution",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="TimelineEntry.timestamp",
|
||||
)
|
||||
red_lead_user = relationship("User", foreign_keys=[red_team_lead])
|
||||
blue_lead_user = relationship("User", foreign_keys=[blue_team_lead])
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_ap_exec_path_id", "attack_path_id"),
|
||||
Index("ix_ap_exec_status", "status"),
|
||||
)
|
||||
|
||||
|
||||
class AttackPathStepResult(Base):
|
||||
"""Result of executing one step in an attack path execution."""
|
||||
|
||||
__tablename__ = "attack_path_step_results"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
execution_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("attack_path_executions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
step_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("attack_path_steps.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
step_order = Column(Integer, nullable=False, default=0) # denormalized for sorting
|
||||
status = Column(
|
||||
Enum(StepResultStatus, name="step_result_status"), nullable=False,
|
||||
default=StepResultStatus.pending,
|
||||
)
|
||||
executed_by = Column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
executed_at = Column(DateTime, nullable=True)
|
||||
detected_at = Column(DateTime, nullable=True)
|
||||
time_to_detect_seconds = Column(Float, nullable=True)
|
||||
detection_asset_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("detection_assets.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
notes = Column(Text, nullable=True)
|
||||
evidence_ids = Column(JSONB, nullable=True, default=list)
|
||||
|
||||
execution = relationship("AttackPathExecution", back_populates="step_results")
|
||||
step = relationship("AttackPathStep")
|
||||
detection_asset = relationship("DetectionAsset", foreign_keys=[detection_asset_id])
|
||||
executor = relationship("User", foreign_keys=[executed_by])
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_ap_stepres_execution_id", "execution_id"),
|
||||
Index("ix_ap_stepres_step_id", "step_id"),
|
||||
)
|
||||
|
||||
|
||||
class TimelineEntry(Base):
|
||||
"""Timestamped Red/Blue action during an execution — used for MTTD/MTTR."""
|
||||
|
||||
__tablename__ = "attack_path_timeline"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
execution_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("attack_path_executions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
step_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("attack_path_steps.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
timestamp = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
actor_side = Column(
|
||||
Enum(TimelineActorSide, name="timeline_actor_side"), nullable=False,
|
||||
)
|
||||
actor_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
entry_type = Column(
|
||||
Enum(TimelineEntryType, name="timeline_entry_type"), nullable=False,
|
||||
)
|
||||
content = Column(Text, nullable=False)
|
||||
extra = Column(JSONB, nullable=True)
|
||||
|
||||
execution = relationship("AttackPathExecution", back_populates="timeline")
|
||||
actor = relationship("User", foreign_keys=[actor_id])
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_timeline_execution_id", "execution_id"),
|
||||
Index("ix_timeline_timestamp", "timestamp"),
|
||||
)
|
||||
250
backend/app/routers/attack_paths.py
Normal file
250
backend/app/routers/attack_paths.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""Phase 10: Attack Paths & Advanced Purple Team router."""
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies.auth import get_current_user, require_any_role
|
||||
from app.schemas.attack_path_schema import (
|
||||
AttackPathCreate, AttackPathUpdate, AttackPathOut,
|
||||
AttackPathStepCreate, AttackPathStepUpdate, AttackPathStepOut,
|
||||
ExecutionCreate, ExecutionOut,
|
||||
StepExecuteRequest, StepResultOut,
|
||||
TimelineEntryCreate, TimelineEntryOut,
|
||||
KillChainMetrics,
|
||||
)
|
||||
from app.services import attack_path_service as svc
|
||||
|
||||
router = APIRouter(prefix="/attack-paths", tags=["attack-paths"])
|
||||
|
||||
|
||||
# ── Attack Paths CRUD ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("", response_model=AttackPathOut, status_code=201)
|
||||
def create_attack_path(
|
||||
body: AttackPathCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
return svc.create_attack_path(db, body.model_dump(), user.id)
|
||||
|
||||
|
||||
@router.get("", response_model=list[AttackPathOut])
|
||||
def list_attack_paths(
|
||||
is_template: Optional[bool] = None,
|
||||
technique_id: Optional[UUID] = None,
|
||||
is_active: Optional[bool] = True,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
paths = svc.list_attack_paths(db, is_template=is_template,
|
||||
technique_id=technique_id, is_active=is_active)
|
||||
# Inject step_count
|
||||
result = []
|
||||
for p in paths:
|
||||
d = AttackPathOut.model_validate(p)
|
||||
d.step_count = len(p.steps)
|
||||
result.append(d)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{path_id}", response_model=AttackPathOut)
|
||||
def get_attack_path(
|
||||
path_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
p = svc.get_attack_path(db, path_id)
|
||||
d = AttackPathOut.model_validate(p)
|
||||
d.step_count = len(p.steps)
|
||||
return d
|
||||
|
||||
|
||||
@router.patch("/{path_id}", response_model=AttackPathOut)
|
||||
def update_attack_path(
|
||||
path_id: UUID,
|
||||
body: AttackPathUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
return svc.update_attack_path(db, path_id, body.model_dump(exclude_unset=True), user.id)
|
||||
|
||||
|
||||
@router.delete("/{path_id}", status_code=204)
|
||||
def delete_attack_path(
|
||||
path_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
||||
):
|
||||
svc.delete_attack_path(db, path_id, user.id)
|
||||
|
||||
|
||||
# ── Steps ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{path_id}/steps", response_model=list[AttackPathStepOut])
|
||||
def list_steps(
|
||||
path_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
path = svc.get_attack_path(db, path_id)
|
||||
return path.steps
|
||||
|
||||
|
||||
@router.post("/{path_id}/steps", response_model=AttackPathStepOut, status_code=201)
|
||||
def add_step(
|
||||
path_id: UUID,
|
||||
body: AttackPathStepCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
return svc.add_step(db, path_id, body.model_dump(), user.id)
|
||||
|
||||
|
||||
@router.patch("/{path_id}/steps/{step_id}", response_model=AttackPathStepOut)
|
||||
def update_step(
|
||||
path_id: UUID,
|
||||
step_id: UUID,
|
||||
body: AttackPathStepUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
return svc.update_step(db, step_id, body.model_dump(exclude_unset=True), user.id)
|
||||
|
||||
|
||||
@router.delete("/{path_id}/steps/{step_id}", status_code=204)
|
||||
def delete_step(
|
||||
path_id: UUID,
|
||||
step_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
svc.delete_step(db, step_id, user.id)
|
||||
|
||||
|
||||
@router.post("/{path_id}/steps/reorder", response_model=list[AttackPathStepOut])
|
||||
def reorder_steps(
|
||||
path_id: UUID,
|
||||
step_ids: list[UUID],
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Pass an ordered list of step UUIDs to reorder the steps."""
|
||||
return svc.reorder_steps(db, path_id, step_ids, user.id)
|
||||
|
||||
|
||||
# ── Executions ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{path_id}/executions", response_model=ExecutionOut, status_code=201)
|
||||
def create_execution(
|
||||
path_id: UUID,
|
||||
body: ExecutionCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
return svc.create_execution(db, path_id, body.model_dump(), user.id)
|
||||
|
||||
|
||||
@router.get("/{path_id}/executions", response_model=list[ExecutionOut])
|
||||
def list_executions(
|
||||
path_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
return svc.list_executions(db, path_id)
|
||||
|
||||
|
||||
@router.get("/executions/{execution_id}", response_model=ExecutionOut)
|
||||
def get_execution(
|
||||
execution_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
return svc.get_execution(db, execution_id)
|
||||
|
||||
|
||||
@router.post("/executions/{execution_id}/start", response_model=ExecutionOut)
|
||||
def start_execution(
|
||||
execution_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
return svc.start_execution(db, execution_id, user.id)
|
||||
|
||||
|
||||
@router.post("/executions/{execution_id}/steps/{step_id}", response_model=StepResultOut)
|
||||
def execute_step(
|
||||
execution_id: UUID,
|
||||
step_id: UUID,
|
||||
body: StepExecuteRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Record the result of one step (detected / not_detected / skipped)."""
|
||||
return svc.execute_step(db, execution_id, step_id, body.model_dump(), user.id)
|
||||
|
||||
|
||||
@router.get("/executions/{execution_id}/steps", response_model=list[StepResultOut])
|
||||
def list_step_results(
|
||||
execution_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
ex = svc.get_execution(db, execution_id)
|
||||
return ex.step_results
|
||||
|
||||
|
||||
@router.post("/executions/{execution_id}/complete", response_model=ExecutionOut)
|
||||
def complete_execution(
|
||||
execution_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Mark execution as complete and compute kill-chain metrics."""
|
||||
return svc.complete_execution(db, execution_id, user.id)
|
||||
|
||||
|
||||
@router.post("/executions/{execution_id}/abort", response_model=ExecutionOut)
|
||||
def abort_execution(
|
||||
execution_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(require_any_role("admin", "red_lead", "blue_lead")),
|
||||
):
|
||||
return svc.abort_execution(db, execution_id, user.id)
|
||||
|
||||
|
||||
# ── Timeline ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/executions/{execution_id}/timeline",
|
||||
response_model=TimelineEntryOut, status_code=201)
|
||||
def add_timeline_entry(
|
||||
execution_id: UUID,
|
||||
body: TimelineEntryCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
return svc.add_timeline_entry(db, execution_id, body.model_dump(), user.id)
|
||||
|
||||
|
||||
@router.get("/executions/{execution_id}/timeline", response_model=list[TimelineEntryOut])
|
||||
def get_timeline(
|
||||
execution_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
return svc.get_timeline(db, execution_id)
|
||||
|
||||
|
||||
# ── Kill-Chain Metrics ────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/executions/{execution_id}/metrics")
|
||||
def get_metrics(
|
||||
execution_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""Return full kill-chain metrics for a completed (or partial) execution."""
|
||||
return svc.get_kill_chain_metrics(db, execution_id)
|
||||
230
backend/app/schemas/attack_path_schema.py
Normal file
230
backend/app/schemas/attack_path_schema.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""Pydantic schemas for Phase 10: Attack Paths & Advanced Purple Team."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
VALID_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",
|
||||
]
|
||||
|
||||
|
||||
# ── Attack Path ───────────────────────────────────────────────────────────────
|
||||
|
||||
class AttackPathCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
objective: Optional[str] = None
|
||||
is_template: bool = False
|
||||
threat_actor_id: Optional[UUID] = None
|
||||
tags: Optional[list[str]] = None
|
||||
|
||||
|
||||
class AttackPathUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
objective: Optional[str] = None
|
||||
is_template: Optional[bool] = None
|
||||
threat_actor_id: Optional[UUID] = None
|
||||
tags: Optional[list[str]] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class AttackPathOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
objective: Optional[str] = None
|
||||
is_template: bool
|
||||
threat_actor_id: Optional[UUID] = None
|
||||
created_by: Optional[UUID] = None
|
||||
tags: Optional[list] = None
|
||||
is_active: bool
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
step_count: Optional[int] = None # injected by service
|
||||
|
||||
|
||||
# ── Attack Path Step ──────────────────────────────────────────────────────────
|
||||
|
||||
class AttackPathStepCreate(BaseModel):
|
||||
order_index: int = 0
|
||||
kill_chain_phase: Optional[str] = None
|
||||
technique_id: Optional[UUID] = None
|
||||
test_id: Optional[UUID] = None
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
expected_detection: bool = True
|
||||
notes: Optional[str] = None
|
||||
|
||||
@field_validator("kill_chain_phase")
|
||||
@classmethod
|
||||
def validate_phase(cls, v):
|
||||
if v is not None and v not in VALID_KILL_CHAIN_PHASES:
|
||||
raise ValueError(f"Invalid kill_chain_phase '{v}'. Valid: {VALID_KILL_CHAIN_PHASES}")
|
||||
return v
|
||||
|
||||
|
||||
class AttackPathStepUpdate(BaseModel):
|
||||
order_index: Optional[int] = None
|
||||
kill_chain_phase: Optional[str] = None
|
||||
technique_id: Optional[UUID] = None
|
||||
test_id: Optional[UUID] = None
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
expected_detection: Optional[bool] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
@field_validator("kill_chain_phase")
|
||||
@classmethod
|
||||
def validate_phase(cls, v):
|
||||
if v is not None and v not in VALID_KILL_CHAIN_PHASES:
|
||||
raise ValueError(f"Invalid kill_chain_phase '{v}'.")
|
||||
return v
|
||||
|
||||
|
||||
class AttackPathStepOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
attack_path_id: UUID
|
||||
order_index: int
|
||||
kill_chain_phase: Optional[str] = None
|
||||
technique_id: Optional[UUID] = None
|
||||
test_id: Optional[UUID] = None
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
expected_detection: bool
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
# ── Execution ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class ExecutionCreate(BaseModel):
|
||||
environment: Optional[str] = None
|
||||
red_team_lead: Optional[UUID] = None
|
||||
blue_team_lead: Optional[UUID] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class ExecutionOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
attack_path_id: UUID
|
||||
status: str
|
||||
environment: Optional[str] = None
|
||||
red_team_lead: Optional[UUID] = None
|
||||
blue_team_lead: Optional[UUID] = None
|
||||
started_by: Optional[UUID] = None
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
notes: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
# metrics
|
||||
total_steps: Optional[int] = None
|
||||
detected_steps: Optional[int] = None
|
||||
not_detected_steps: Optional[int] = None
|
||||
skipped_steps: Optional[int] = None
|
||||
detection_rate: Optional[float] = None
|
||||
mttd_seconds: Optional[float] = None
|
||||
furthest_undetected_step: Optional[int] = None
|
||||
|
||||
|
||||
# ── Step Result ───────────────────────────────────────────────────────────────
|
||||
|
||||
class StepExecuteRequest(BaseModel):
|
||||
status: str # detected / not_detected / skipped
|
||||
executed_at: Optional[datetime] = None
|
||||
detected_at: Optional[datetime] = None
|
||||
detection_asset_id: Optional[UUID] = None
|
||||
notes: Optional[str] = None
|
||||
evidence_ids: Optional[list[UUID]] = None
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def validate_status(cls, v):
|
||||
valid = ("detected", "not_detected", "skipped", "executing")
|
||||
if v not in valid:
|
||||
raise ValueError(f"status must be one of {valid}")
|
||||
return v
|
||||
|
||||
|
||||
class StepResultOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
execution_id: UUID
|
||||
step_id: UUID
|
||||
step_order: int
|
||||
status: str
|
||||
executed_by: Optional[UUID] = None
|
||||
executed_at: Optional[datetime] = None
|
||||
detected_at: Optional[datetime] = None
|
||||
time_to_detect_seconds: Optional[float] = None
|
||||
detection_asset_id: Optional[UUID] = None
|
||||
notes: Optional[str] = None
|
||||
evidence_ids: Optional[list] = None
|
||||
|
||||
|
||||
# ── Timeline ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class TimelineEntryCreate(BaseModel):
|
||||
actor_side: str
|
||||
entry_type: str
|
||||
content: str
|
||||
step_id: Optional[UUID] = None
|
||||
timestamp: Optional[datetime] = None
|
||||
extra: Optional[dict] = None
|
||||
|
||||
@field_validator("actor_side")
|
||||
@classmethod
|
||||
def validate_side(cls, v):
|
||||
if v not in ("red", "blue", "system"):
|
||||
raise ValueError("actor_side must be red, blue or system")
|
||||
return v
|
||||
|
||||
@field_validator("entry_type")
|
||||
@classmethod
|
||||
def validate_type(cls, v):
|
||||
valid = ("action", "detection", "note", "phase_transition", "flag")
|
||||
if v not in valid:
|
||||
raise ValueError(f"entry_type must be one of {valid}")
|
||||
return v
|
||||
|
||||
|
||||
class TimelineEntryOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
execution_id: UUID
|
||||
step_id: Optional[UUID] = None
|
||||
timestamp: datetime
|
||||
actor_side: str
|
||||
actor_id: Optional[UUID] = None
|
||||
entry_type: str
|
||||
content: str
|
||||
extra: Optional[dict] = None
|
||||
|
||||
|
||||
# ── Metrics ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class KillChainMetrics(BaseModel):
|
||||
execution_id: UUID
|
||||
total_steps: int
|
||||
detected_steps: int
|
||||
not_detected_steps: int
|
||||
skipped_steps: int
|
||||
detection_rate: float # 0.0–1.0
|
||||
mttd_seconds: Optional[float] # mean time to detect
|
||||
furthest_undetected_step: Optional[int]
|
||||
furthest_undetected_phase: Optional[str]
|
||||
step_breakdown: list[dict] # per-step detail
|
||||
phase_summary: dict # detection rate per kill-chain phase
|
||||
553
backend/app/services/attack_path_service.py
Normal file
553
backend/app/services/attack_path_service.py
Normal file
@@ -0,0 +1,553 @@
|
||||
"""Phase 10: Attack Path CRUD service."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.models.attack_path import (
|
||||
AttackPath, AttackPathStep, AttackPathExecution,
|
||||
AttackPathStepResult, TimelineEntry,
|
||||
ExecutionStatus, StepResultStatus, TimelineActorSide, TimelineEntryType,
|
||||
)
|
||||
from app.domain.exceptions import EntityNotFoundError
|
||||
from app.services import audit_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
# ── Attack Path CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
def create_attack_path(db: Session, data: dict, user_id: UUID) -> AttackPath:
|
||||
path = AttackPath(
|
||||
name=data["name"],
|
||||
description=data.get("description"),
|
||||
objective=data.get("objective"),
|
||||
is_template=data.get("is_template", False),
|
||||
threat_actor_id=data.get("threat_actor_id"),
|
||||
tags=data.get("tags") or [],
|
||||
created_by=user_id,
|
||||
)
|
||||
db.add(path)
|
||||
db.commit()
|
||||
db.refresh(path)
|
||||
audit_service.log_action(
|
||||
db, user_id, "ATTACK_PATH_CREATED", "attack_path", str(path.id),
|
||||
details={"name": path.name, "is_template": path.is_template},
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def get_attack_path(db: Session, path_id: UUID) -> AttackPath:
|
||||
path = (
|
||||
db.query(AttackPath)
|
||||
.options(joinedload(AttackPath.steps))
|
||||
.filter(AttackPath.id == path_id)
|
||||
.first()
|
||||
)
|
||||
if not path:
|
||||
raise EntityNotFoundError("AttackPath", str(path_id))
|
||||
return path
|
||||
|
||||
|
||||
def list_attack_paths(
|
||||
db: Session,
|
||||
is_template: Optional[bool] = None,
|
||||
technique_id: Optional[UUID] = None,
|
||||
is_active: Optional[bool] = True,
|
||||
) -> list[AttackPath]:
|
||||
q = db.query(AttackPath)
|
||||
if is_active is not None:
|
||||
q = q.filter(AttackPath.is_active == is_active)
|
||||
if is_template is not None:
|
||||
q = q.filter(AttackPath.is_template == is_template)
|
||||
if technique_id:
|
||||
q = q.join(AttackPathStep).filter(AttackPathStep.technique_id == technique_id)
|
||||
return q.order_by(AttackPath.created_at.desc()).all()
|
||||
|
||||
|
||||
def update_attack_path(db: Session, path_id: UUID, data: dict, user_id: UUID) -> AttackPath:
|
||||
path = db.query(AttackPath).filter(AttackPath.id == path_id).first()
|
||||
if not path:
|
||||
raise EntityNotFoundError("AttackPath", str(path_id))
|
||||
for k, v in data.items():
|
||||
if v is not None and hasattr(path, k):
|
||||
setattr(path, k, v)
|
||||
path.updated_at = _now()
|
||||
db.commit()
|
||||
db.refresh(path)
|
||||
return path
|
||||
|
||||
|
||||
def delete_attack_path(db: Session, path_id: UUID, user_id: UUID) -> None:
|
||||
path = db.query(AttackPath).filter(AttackPath.id == path_id).first()
|
||||
if not path:
|
||||
raise EntityNotFoundError("AttackPath", str(path_id))
|
||||
path.is_active = False
|
||||
path.updated_at = _now()
|
||||
db.commit()
|
||||
audit_service.log_action(db, user_id, "ATTACK_PATH_ARCHIVED", "attack_path", str(path_id))
|
||||
|
||||
|
||||
# ── Steps CRUD ────────────────────────────────────────────────────────────────
|
||||
|
||||
def add_step(db: Session, path_id: UUID, data: dict, user_id: UUID) -> AttackPathStep:
|
||||
path = db.query(AttackPath).filter(AttackPath.id == path_id).first()
|
||||
if not path:
|
||||
raise EntityNotFoundError("AttackPath", str(path_id))
|
||||
|
||||
# Auto-assign order_index if not provided
|
||||
if data.get("order_index") is None:
|
||||
max_idx = db.query(AttackPathStep).filter(
|
||||
AttackPathStep.attack_path_id == path_id
|
||||
).count()
|
||||
data["order_index"] = max_idx
|
||||
|
||||
step = AttackPathStep(
|
||||
attack_path_id=path_id,
|
||||
order_index=data.get("order_index", 0),
|
||||
kill_chain_phase=data.get("kill_chain_phase"),
|
||||
technique_id=data.get("technique_id"),
|
||||
test_id=data.get("test_id"),
|
||||
name=data.get("name"),
|
||||
description=data.get("description"),
|
||||
expected_detection=data.get("expected_detection", True),
|
||||
notes=data.get("notes"),
|
||||
)
|
||||
db.add(step)
|
||||
path.updated_at = _now()
|
||||
db.commit()
|
||||
db.refresh(step)
|
||||
return step
|
||||
|
||||
|
||||
def update_step(db: Session, step_id: UUID, data: dict, user_id: UUID) -> AttackPathStep:
|
||||
step = db.query(AttackPathStep).filter(AttackPathStep.id == step_id).first()
|
||||
if not step:
|
||||
raise EntityNotFoundError("AttackPathStep", str(step_id))
|
||||
for k, v in data.items():
|
||||
if v is not None and hasattr(step, k):
|
||||
setattr(step, k, v)
|
||||
db.commit()
|
||||
db.refresh(step)
|
||||
return step
|
||||
|
||||
|
||||
def delete_step(db: Session, step_id: UUID, user_id: UUID) -> None:
|
||||
step = db.query(AttackPathStep).filter(AttackPathStep.id == step_id).first()
|
||||
if not step:
|
||||
raise EntityNotFoundError("AttackPathStep", str(step_id))
|
||||
db.delete(step)
|
||||
db.commit()
|
||||
|
||||
|
||||
def reorder_steps(db: Session, path_id: UUID, step_ids: list[UUID], user_id: UUID) -> list[AttackPathStep]:
|
||||
"""Reorder steps by providing ordered list of step IDs."""
|
||||
path = db.query(AttackPath).filter(AttackPath.id == path_id).first()
|
||||
if not path:
|
||||
raise EntityNotFoundError("AttackPath", str(path_id))
|
||||
|
||||
for idx, step_id in enumerate(step_ids):
|
||||
db.query(AttackPathStep).filter(
|
||||
AttackPathStep.id == step_id,
|
||||
AttackPathStep.attack_path_id == path_id,
|
||||
).update({"order_index": idx})
|
||||
|
||||
path.updated_at = _now()
|
||||
db.commit()
|
||||
|
||||
return (
|
||||
db.query(AttackPathStep)
|
||||
.filter(AttackPathStep.attack_path_id == path_id)
|
||||
.order_by(AttackPathStep.order_index)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
# ── Executions ────────────────────────────────────────────────────────────────
|
||||
|
||||
def create_execution(
|
||||
db: Session, path_id: UUID, data: dict, user_id: UUID
|
||||
) -> AttackPathExecution:
|
||||
path = db.query(AttackPath).filter(AttackPath.id == path_id).first()
|
||||
if not path:
|
||||
raise EntityNotFoundError("AttackPath", str(path_id))
|
||||
|
||||
execution = AttackPathExecution(
|
||||
attack_path_id=path_id,
|
||||
status=ExecutionStatus.planned,
|
||||
environment=data.get("environment"),
|
||||
red_team_lead=data.get("red_team_lead"),
|
||||
blue_team_lead=data.get("blue_team_lead"),
|
||||
notes=data.get("notes"),
|
||||
started_by=user_id,
|
||||
)
|
||||
db.add(execution)
|
||||
db.flush()
|
||||
|
||||
# Pre-create pending step results for every step in the path
|
||||
steps = (
|
||||
db.query(AttackPathStep)
|
||||
.filter(AttackPathStep.attack_path_id == path_id)
|
||||
.order_by(AttackPathStep.order_index)
|
||||
.all()
|
||||
)
|
||||
for step in steps:
|
||||
result = AttackPathStepResult(
|
||||
execution_id=execution.id,
|
||||
step_id=step.id,
|
||||
step_order=step.order_index,
|
||||
status=StepResultStatus.pending,
|
||||
)
|
||||
db.add(result)
|
||||
|
||||
db.commit()
|
||||
db.refresh(execution)
|
||||
|
||||
# Auto-add system timeline entry
|
||||
_add_system_entry(
|
||||
db, execution.id,
|
||||
entry_type=TimelineEntryType.phase_transition,
|
||||
content=f"Execution created for '{path.name}' with {len(steps)} steps.",
|
||||
)
|
||||
|
||||
audit_service.log_action(
|
||||
db, user_id, "ATTACK_PATH_EXECUTION_STARTED", "attack_path_execution",
|
||||
str(execution.id),
|
||||
details={"path_id": str(path_id), "path_name": path.name, "steps": len(steps)},
|
||||
)
|
||||
return execution
|
||||
|
||||
|
||||
def get_execution(db: Session, execution_id: UUID) -> AttackPathExecution:
|
||||
ex = (
|
||||
db.query(AttackPathExecution)
|
||||
.options(
|
||||
joinedload(AttackPathExecution.step_results),
|
||||
joinedload(AttackPathExecution.timeline),
|
||||
)
|
||||
.filter(AttackPathExecution.id == execution_id)
|
||||
.first()
|
||||
)
|
||||
if not ex:
|
||||
raise EntityNotFoundError("AttackPathExecution", str(execution_id))
|
||||
return ex
|
||||
|
||||
|
||||
def list_executions(db: Session, path_id: UUID) -> list[AttackPathExecution]:
|
||||
path = db.query(AttackPath).filter(AttackPath.id == path_id).first()
|
||||
if not path:
|
||||
raise EntityNotFoundError("AttackPath", str(path_id))
|
||||
return (
|
||||
db.query(AttackPathExecution)
|
||||
.filter(AttackPathExecution.attack_path_id == path_id)
|
||||
.order_by(AttackPathExecution.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def start_execution(db: Session, execution_id: UUID, user_id: UUID) -> AttackPathExecution:
|
||||
ex = db.query(AttackPathExecution).filter(AttackPathExecution.id == execution_id).first()
|
||||
if not ex:
|
||||
raise EntityNotFoundError("AttackPathExecution", str(execution_id))
|
||||
if ex.status not in (ExecutionStatus.planned,):
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(400, "Execution is not in 'planned' state")
|
||||
ex.status = ExecutionStatus.in_progress
|
||||
ex.started_at = _now()
|
||||
db.commit()
|
||||
db.refresh(ex)
|
||||
_add_system_entry(db, execution_id, TimelineEntryType.phase_transition,
|
||||
"Execution started.", actor_id=user_id, actor_side=TimelineActorSide.system)
|
||||
return ex
|
||||
|
||||
|
||||
# ── Step Execution ────────────────────────────────────────────────────────────
|
||||
|
||||
def execute_step(
|
||||
db: Session,
|
||||
execution_id: UUID,
|
||||
step_id: UUID,
|
||||
data: dict,
|
||||
user_id: UUID,
|
||||
) -> AttackPathStepResult:
|
||||
"""Record the result of executing one step."""
|
||||
ex = db.query(AttackPathExecution).filter(AttackPathExecution.id == execution_id).first()
|
||||
if not ex:
|
||||
raise EntityNotFoundError("AttackPathExecution", str(execution_id))
|
||||
if ex.status not in (ExecutionStatus.in_progress, ExecutionStatus.planned):
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(400, "Execution must be in_progress to record step results")
|
||||
|
||||
# Auto-start if still planned
|
||||
if ex.status == ExecutionStatus.planned:
|
||||
ex.status = ExecutionStatus.in_progress
|
||||
ex.started_at = _now()
|
||||
|
||||
result = (
|
||||
db.query(AttackPathStepResult)
|
||||
.filter(
|
||||
AttackPathStepResult.execution_id == execution_id,
|
||||
AttackPathStepResult.step_id == step_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not result:
|
||||
# Create on-the-fly if step was added after execution started
|
||||
step = db.query(AttackPathStep).filter(AttackPathStep.id == step_id).first()
|
||||
if not step:
|
||||
raise EntityNotFoundError("AttackPathStep", str(step_id))
|
||||
result = AttackPathStepResult(
|
||||
execution_id=execution_id,
|
||||
step_id=step_id,
|
||||
step_order=step.order_index,
|
||||
)
|
||||
db.add(result)
|
||||
|
||||
now = _now()
|
||||
new_status = StepResultStatus(data["status"])
|
||||
result.status = new_status
|
||||
result.executed_by = user_id
|
||||
result.executed_at = data.get("executed_at") or now
|
||||
result.notes = data.get("notes")
|
||||
result.evidence_ids = [str(e) for e in (data.get("evidence_ids") or [])]
|
||||
result.detection_asset_id = data.get("detection_asset_id")
|
||||
|
||||
if new_status == StepResultStatus.detected:
|
||||
result.detected_at = data.get("detected_at") or now
|
||||
if result.executed_at:
|
||||
delta = (result.detected_at - result.executed_at).total_seconds()
|
||||
result.time_to_detect_seconds = max(0.0, delta)
|
||||
|
||||
db.commit()
|
||||
db.refresh(result)
|
||||
|
||||
# Add timeline entry
|
||||
step_obj = db.query(AttackPathStep).filter(AttackPathStep.id == step_id).first()
|
||||
step_name = step_obj.name or (step_obj.kill_chain_phase or "Unknown step")
|
||||
actor_side = TimelineActorSide.red if new_status != StepResultStatus.detected else TimelineActorSide.blue
|
||||
entry_type = (
|
||||
TimelineEntryType.detection if new_status == StepResultStatus.detected
|
||||
else TimelineEntryType.action
|
||||
)
|
||||
content = (
|
||||
f"Step '{step_name}' marked as {new_status.value}."
|
||||
+ (f" Detected in {result.time_to_detect_seconds:.0f}s." if result.time_to_detect_seconds else "")
|
||||
)
|
||||
_add_system_entry(
|
||||
db, execution_id, entry_type, content,
|
||||
actor_id=user_id, actor_side=actor_side, step_id=step_id,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── Completion & Metrics ──────────────────────────────────────────────────────
|
||||
|
||||
def complete_execution(db: Session, execution_id: UUID, user_id: UUID) -> AttackPathExecution:
|
||||
"""Mark execution complete and compute all kill-chain metrics."""
|
||||
ex = db.query(AttackPathExecution).filter(AttackPathExecution.id == execution_id).first()
|
||||
if not ex:
|
||||
raise EntityNotFoundError("AttackPathExecution", str(execution_id))
|
||||
|
||||
results = (
|
||||
db.query(AttackPathStepResult)
|
||||
.filter(AttackPathStepResult.execution_id == execution_id)
|
||||
.order_by(AttackPathStepResult.step_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
total = len(results)
|
||||
detected = sum(1 for r in results if r.status == StepResultStatus.detected)
|
||||
not_detected = sum(1 for r in results if r.status == StepResultStatus.not_detected)
|
||||
skipped = sum(1 for r in results if r.status == StepResultStatus.skipped)
|
||||
|
||||
detection_rate = (detected / total) if total > 0 else 0.0
|
||||
|
||||
ttds = [r.time_to_detect_seconds for r in results
|
||||
if r.time_to_detect_seconds is not None]
|
||||
mttd = (sum(ttds) / len(ttds)) if ttds else None
|
||||
|
||||
# Furthest undetected step (highest order_index with not_detected status)
|
||||
undetected = [r for r in results if r.status == StepResultStatus.not_detected]
|
||||
furthest = max((r.step_order for r in undetected), default=None)
|
||||
|
||||
ex.status = ExecutionStatus.completed
|
||||
ex.completed_at = _now()
|
||||
ex.total_steps = total
|
||||
ex.detected_steps = detected
|
||||
ex.not_detected_steps = not_detected
|
||||
ex.skipped_steps = skipped
|
||||
ex.detection_rate = round(detection_rate, 4)
|
||||
ex.mttd_seconds = round(mttd, 1) if mttd is not None else None
|
||||
ex.furthest_undetected_step = furthest
|
||||
|
||||
db.commit()
|
||||
db.refresh(ex)
|
||||
|
||||
_add_system_entry(
|
||||
db, execution_id, TimelineEntryType.phase_transition,
|
||||
f"Execution completed. Detection rate: {detection_rate:.0%}. "
|
||||
f"Detected {detected}/{total} steps. "
|
||||
+ (f"MTTD: {mttd:.0f}s." if mttd else ""),
|
||||
actor_id=user_id, actor_side=TimelineActorSide.system,
|
||||
)
|
||||
|
||||
audit_service.log_action(
|
||||
db, user_id, "ATTACK_PATH_EXECUTION_COMPLETED", "attack_path_execution",
|
||||
str(execution_id),
|
||||
details={"detection_rate": detection_rate, "mttd_seconds": mttd,
|
||||
"detected": detected, "total": total},
|
||||
)
|
||||
return ex
|
||||
|
||||
|
||||
def abort_execution(db: Session, execution_id: UUID, user_id: UUID) -> AttackPathExecution:
|
||||
ex = db.query(AttackPathExecution).filter(AttackPathExecution.id == execution_id).first()
|
||||
if not ex:
|
||||
raise EntityNotFoundError("AttackPathExecution", str(execution_id))
|
||||
ex.status = ExecutionStatus.aborted
|
||||
ex.completed_at = _now()
|
||||
db.commit()
|
||||
db.refresh(ex)
|
||||
_add_system_entry(db, execution_id, TimelineEntryType.flag, "Execution aborted.",
|
||||
actor_id=user_id, actor_side=TimelineActorSide.system)
|
||||
return ex
|
||||
|
||||
|
||||
# ── Timeline ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def add_timeline_entry(
|
||||
db: Session, execution_id: UUID, data: dict, user_id: UUID
|
||||
) -> TimelineEntry:
|
||||
ex = db.query(AttackPathExecution).filter(AttackPathExecution.id == execution_id).first()
|
||||
if not ex:
|
||||
raise EntityNotFoundError("AttackPathExecution", str(execution_id))
|
||||
|
||||
entry = TimelineEntry(
|
||||
execution_id=execution_id,
|
||||
step_id=data.get("step_id"),
|
||||
timestamp=data.get("timestamp") or _now(),
|
||||
actor_side=TimelineActorSide(data["actor_side"]),
|
||||
actor_id=user_id,
|
||||
entry_type=TimelineEntryType(data["entry_type"]),
|
||||
content=data["content"],
|
||||
extra=data.get("extra"),
|
||||
)
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def get_timeline(db: Session, execution_id: UUID) -> list[TimelineEntry]:
|
||||
ex = db.query(AttackPathExecution).filter(AttackPathExecution.id == execution_id).first()
|
||||
if not ex:
|
||||
raise EntityNotFoundError("AttackPathExecution", str(execution_id))
|
||||
return (
|
||||
db.query(TimelineEntry)
|
||||
.filter(TimelineEntry.execution_id == execution_id)
|
||||
.order_by(TimelineEntry.timestamp.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
# ── Kill-Chain Metrics ────────────────────────────────────────────────────────
|
||||
|
||||
def get_kill_chain_metrics(db: Session, execution_id: UUID) -> dict:
|
||||
ex = db.query(AttackPathExecution).filter(AttackPathExecution.id == execution_id).first()
|
||||
if not ex:
|
||||
raise EntityNotFoundError("AttackPathExecution", str(execution_id))
|
||||
|
||||
results = (
|
||||
db.query(AttackPathStepResult)
|
||||
.filter(AttackPathStepResult.execution_id == execution_id)
|
||||
.order_by(AttackPathStepResult.step_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
step_breakdown = []
|
||||
phase_detected: dict[str, list] = {}
|
||||
|
||||
for r in results:
|
||||
step = db.query(AttackPathStep).filter(AttackPathStep.id == r.step_id).first()
|
||||
phase = step.kill_chain_phase if step else None
|
||||
entry = {
|
||||
"step_id": str(r.step_id),
|
||||
"step_order": r.step_order,
|
||||
"step_name": step.name if step else None,
|
||||
"kill_chain_phase": phase,
|
||||
"status": r.status.value if hasattr(r.status, "value") else r.status,
|
||||
"executed_at": r.executed_at.isoformat() if r.executed_at else None,
|
||||
"detected_at": r.detected_at.isoformat() if r.detected_at else None,
|
||||
"time_to_detect_seconds": r.time_to_detect_seconds,
|
||||
"detection_asset_id": str(r.detection_asset_id) if r.detection_asset_id else None,
|
||||
}
|
||||
step_breakdown.append(entry)
|
||||
if phase:
|
||||
phase_detected.setdefault(phase, []).append(
|
||||
r.status == StepResultStatus.detected
|
||||
)
|
||||
|
||||
phase_summary = {
|
||||
phase: {
|
||||
"total": len(v),
|
||||
"detected": sum(v),
|
||||
"detection_rate": round(sum(v) / len(v), 3) if v else 0.0,
|
||||
}
|
||||
for phase, v in phase_detected.items()
|
||||
}
|
||||
|
||||
# Furthest undetected phase
|
||||
furthest_undetected_phase = None
|
||||
if ex.furthest_undetected_step is not None:
|
||||
for r in reversed(results):
|
||||
if r.step_order == ex.furthest_undetected_step:
|
||||
step = db.query(AttackPathStep).filter(AttackPathStep.id == r.step_id).first()
|
||||
if step:
|
||||
furthest_undetected_phase = step.kill_chain_phase
|
||||
break
|
||||
|
||||
return {
|
||||
"execution_id": str(execution_id),
|
||||
"total_steps": ex.total_steps or len(results),
|
||||
"detected_steps": ex.detected_steps or 0,
|
||||
"not_detected_steps": ex.not_detected_steps or 0,
|
||||
"skipped_steps": ex.skipped_steps or 0,
|
||||
"detection_rate": ex.detection_rate or 0.0,
|
||||
"mttd_seconds": ex.mttd_seconds,
|
||||
"furthest_undetected_step": ex.furthest_undetected_step,
|
||||
"furthest_undetected_phase": furthest_undetected_phase,
|
||||
"step_breakdown": step_breakdown,
|
||||
"phase_summary": phase_summary,
|
||||
}
|
||||
|
||||
|
||||
# ── Helper ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _add_system_entry(
|
||||
db: Session,
|
||||
execution_id: UUID,
|
||||
entry_type: TimelineEntryType,
|
||||
content: str,
|
||||
actor_id: Optional[UUID] = None,
|
||||
actor_side: TimelineActorSide = TimelineActorSide.system,
|
||||
step_id: Optional[UUID] = None,
|
||||
) -> None:
|
||||
entry = TimelineEntry(
|
||||
execution_id=execution_id,
|
||||
step_id=step_id,
|
||||
timestamp=_now(),
|
||||
actor_side=actor_side,
|
||||
actor_id=actor_id,
|
||||
entry_type=entry_type,
|
||||
content=content,
|
||||
)
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
Reference in New Issue
Block a user