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:
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)
|
||||
Reference in New Issue
Block a user