Files
Aegis/backend/app/routers/detection_lifecycle.py
kitos 1fe150963c
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
feat(dlm): Phase 8 — Detection Lifecycle Management [FASE-8]
Tasks 8.1-8.5:

Models (8.1):
- DetectionAsset: SIEM/EDR/Sigma rule assets with auto-hash
- DetectionTechniqueMapping: N:M asset ↔ technique coverage
- DetectionValidation: immutable validation records with expiry
- TechniqueConfidenceScore: computed multi-factor confidence
- InfrastructureChangeLog: infra changes that invalidate detections
- DecayPolicy: configurable freshness thresholds per platform/tactic

Services (8.2, 8.3):
- detection_asset_service: CRUD + SHA-256 rule hashing + auto-
  invalidation on rule/infra changes
- decay_engine_service: daily decay engine — expires stale validations,
  recalculates confidence (recency/coverage/health/diversity factors),
  processes infrastructure change propagation

Router (8.4): 15 endpoints under /api/v1/detection-lifecycle:
  assets CRUD, technique mappings, validations, confidence scores,
  infrastructure changes, decay trigger, executive dashboard

Scheduler (8.3): decay engine runs daily at 02:00
Seed (8.5): default policy (90/180/365d) + strict initial-access policy
Migration: b034dlm (6 tables, 11 indexes)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:45:16 +02:00

303 lines
13 KiB
Python

"""Detection Lifecycle Management router."""
import hashlib
from datetime import datetime, timezone, timedelta
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, 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_any_role
from app.domain.exceptions import EntityNotFoundError
from app.models.detection_lifecycle import (
DetectionAsset, DetectionTechniqueMapping, DetectionValidation,
TechniqueConfidenceScore, InfrastructureChangeLog,
)
from app.schemas.detection_lifecycle_schema import (
DetectionAssetCreate, DetectionAssetUpdate, DetectionAssetOut,
DetectionValidationCreate, DetectionValidationOut,
TechniqueConfidenceOut,
InfrastructureChangeCreate, InfrastructureChangeOut,
)
from app.services import detection_asset_service, decay_engine_service, audit_service
router = APIRouter(prefix="/detection-lifecycle", tags=["detection-lifecycle"])
def _now() -> datetime:
return datetime.now(timezone.utc)
# ── Detection Assets ─────────────────────────────────────────────────────────
@router.post("/assets", response_model=DetectionAssetOut, status_code=201)
def create_asset(body: DetectionAssetCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
asset = detection_asset_service.create_detection_asset(db, body.model_dump(), user.id)
return asset
@router.get("/assets", response_model=list[DetectionAssetOut])
def list_assets(
platform: Optional[str] = None,
asset_type: Optional[str] = None,
health_status: Optional[str] = None,
technique_id: Optional[UUID] = None,
is_active: Optional[bool] = True,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
return detection_asset_service.list_assets(db, platform=platform, asset_type=asset_type, health_status=health_status, technique_id=technique_id, is_active=is_active)
@router.get("/assets/{asset_id}", response_model=DetectionAssetOut)
def get_asset(asset_id: UUID, db: Session = Depends(get_db), user=Depends(get_current_user)):
return detection_asset_service.get_asset_with_details(db, asset_id)
@router.patch("/assets/{asset_id}", response_model=DetectionAssetOut)
def update_asset(asset_id: UUID, body: DetectionAssetUpdate, db: Session = Depends(get_db), user=Depends(get_current_user)):
return detection_asset_service.update_detection_asset(db, asset_id, body.model_dump(exclude_unset=True), user.id)
@router.delete("/assets/{asset_id}", status_code=204)
def delete_asset(asset_id: UUID, db: Session = Depends(get_db), user=Depends(require_any_role("red_lead", "blue_lead"))):
asset = db.query(DetectionAsset).filter(DetectionAsset.id == asset_id).first()
if not asset:
raise EntityNotFoundError("DetectionAsset", str(asset_id))
asset.is_active = False
db.commit()
# ── Technique Mappings ───────────────────────────────────────────────────────
@router.post("/assets/{asset_id}/techniques/{technique_id}")
def map_technique(
asset_id: UUID, technique_id: UUID,
coverage_type: str = Query("detect"),
confidence_level: str = Query("medium"),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
mapping = DetectionTechniqueMapping(
detection_asset_id=asset_id, technique_id=technique_id,
coverage_type=coverage_type, confidence_level=confidence_level,
)
db.add(mapping)
db.commit()
return {"message": "Technique mapped", "mapping_id": str(mapping.id)}
@router.get("/techniques/{technique_id}/detections")
def get_technique_detections(technique_id: UUID, db: Session = Depends(get_db), user=Depends(get_current_user)):
return detection_asset_service.get_technique_detection_summary(db, technique_id)
# ── Validations ──────────────────────────────────────────────────────────────
@router.post("/validations", response_model=DetectionValidationOut, status_code=201)
def create_validation(body: DetectionValidationCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
asset = db.query(DetectionAsset).filter(DetectionAsset.id == body.detection_asset_id).first()
if not asset:
raise EntityNotFoundError("DetectionAsset", str(body.detection_asset_id))
now = _now()
validation = DetectionValidation(
detection_asset_id=body.detection_asset_id,
technique_id=body.technique_id,
test_id=body.test_id,
validation_result=body.validation_result,
validation_method=body.validation_method,
notes=body.notes,
evidence_ids=[str(e) for e in (body.evidence_ids or [])],
validated_by=user.id,
validated_at=now,
expires_at=now + timedelta(days=body.validity_days),
rule_hash_at_validation=asset.rule_hash,
log_source_version_at_validation=asset.log_source_version,
infrastructure_hash_at_validation=asset.infrastructure_hash,
)
data = f"{validation.detection_asset_id}:{validation.validated_by}:{validation.validation_result}:{validation.validated_at}"
validation.integrity_hash = hashlib.sha256(data.encode()).hexdigest()
db.add(validation)
db.commit()
db.refresh(validation)
if body.technique_id:
decay_engine_service.calculate_confidence_for_technique(db, body.technique_id)
audit_service.log_action(db, user.id, "DETECTION_VALIDATED", "detection_validation", str(validation.id),
details={"asset_id": str(body.detection_asset_id), "result": body.validation_result, "validity_days": body.validity_days})
return validation
@router.get("/validations", response_model=list[DetectionValidationOut])
def list_validations(
asset_id: Optional[UUID] = None,
technique_id: Optional[UUID] = None,
is_valid: Optional[bool] = None,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
query = db.query(DetectionValidation)
if asset_id:
query = query.filter(DetectionValidation.detection_asset_id == asset_id)
if technique_id:
query = query.filter(DetectionValidation.technique_id == technique_id)
if is_valid is not None:
query = query.filter(DetectionValidation.is_valid == is_valid)
return query.order_by(DetectionValidation.validated_at.desc()).all()
@router.post("/validations/{validation_id}/invalidate")
def invalidate_validation(
validation_id: UUID,
reason: str = Query(...),
details: Optional[str] = None,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "blue_lead")),
):
validation = db.query(DetectionValidation).filter(DetectionValidation.id == validation_id).first()
if not validation:
raise EntityNotFoundError("DetectionValidation", str(validation_id))
from app.models.detection_lifecycle import InvalidationReason
try:
reason_enum = InvalidationReason(reason)
except ValueError:
reason_enum = InvalidationReason.manual
validation.is_valid = False
validation.invalidated_at = _now()
validation.invalidation_reason = reason_enum
validation.invalidation_details = details
validation.invalidated_by = user.id
db.commit()
return {"message": "Validation invalidated"}
# ── Confidence Scores ────────────────────────────────────────────────────────
@router.get("/confidence", response_model=list[TechniqueConfidenceOut])
def list_confidence_scores(
confidence_level: Optional[str] = None,
min_score: Optional[float] = None,
max_score: Optional[float] = None,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
query = db.query(TechniqueConfidenceScore)
if confidence_level:
query = query.filter(TechniqueConfidenceScore.confidence_level == confidence_level)
if min_score is not None:
query = query.filter(TechniqueConfidenceScore.confidence_score >= min_score)
if max_score is not None:
query = query.filter(TechniqueConfidenceScore.confidence_score <= max_score)
return query.order_by(TechniqueConfidenceScore.confidence_score.asc()).all()
@router.get("/confidence/{technique_id}", response_model=TechniqueConfidenceOut)
def get_technique_confidence(
technique_id: UUID,
recalculate: bool = Query(False),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
if recalculate:
return decay_engine_service.calculate_confidence_for_technique(db, technique_id)
score = db.query(TechniqueConfidenceScore).filter(TechniqueConfidenceScore.technique_id == technique_id).first()
if not score:
return decay_engine_service.calculate_confidence_for_technique(db, technique_id)
return score
# ── Infrastructure Changes ───────────────────────────────────────────────────
@router.post("/infrastructure-changes", response_model=InfrastructureChangeOut, status_code=201)
def report_infrastructure_change(
body: InfrastructureChangeCreate,
db: Session = Depends(get_db),
user=Depends(require_any_role("admin", "blue_lead")),
):
change = InfrastructureChangeLog(
change_type=body.change_type,
description=body.description,
affected_platforms=body.affected_platforms,
affected_log_sources=body.affected_log_sources,
change_date=body.change_date or _now(),
auto_invalidate=body.auto_invalidate,
reported_by=user.id,
)
db.add(change)
db.commit()
db.refresh(change)
if change.auto_invalidate:
decay_engine_service.process_infrastructure_change(db, change.id)
db.refresh(change)
audit_service.log_action(db, user.id, "INFRASTRUCTURE_CHANGE_REPORTED", "infrastructure_change", str(change.id),
details={"type": body.change_type, "invalidated_count": change.invalidated_count})
return change
@router.get("/infrastructure-changes", response_model=list[InfrastructureChangeOut])
def list_infrastructure_changes(
days: int = Query(90, ge=1, le=730),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
cutoff = _now() - timedelta(days=days)
return db.query(InfrastructureChangeLog).filter(InfrastructureChangeLog.change_date >= cutoff).order_by(InfrastructureChangeLog.change_date.desc()).all()
# ── Decay Engine Control ─────────────────────────────────────────────────────
@router.post("/decay-engine/run")
def trigger_decay_engine(db: Session = Depends(get_db), user=Depends(require_any_role("admin"))):
results = decay_engine_service.run_decay_engine(db)
return {"message": "Decay engine completed", "results": results}
# ── Dashboard ────────────────────────────────────────────────────────────────
@router.get("/dashboard")
def lifecycle_dashboard(db: Session = Depends(get_db), user=Depends(get_current_user)):
now = _now()
health_dist = dict(
db.query(DetectionAsset.health_status, func.count(DetectionAsset.id))
.filter(DetectionAsset.is_active == True)
.group_by(DetectionAsset.health_status)
.all()
)
confidence_dist = dict(
db.query(TechniqueConfidenceScore.confidence_level, func.count(TechniqueConfidenceScore.id))
.group_by(TechniqueConfidenceScore.confidence_level)
.all()
)
expiring_soon = db.query(func.count(DetectionValidation.id)).filter(
DetectionValidation.is_valid == True,
DetectionValidation.expires_at <= (now + timedelta(days=7)),
).scalar() or 0
total_assets = db.query(func.count(DetectionAsset.id)).filter(DetectionAsset.is_active == True).scalar() or 0
total_valid = db.query(func.count(DetectionValidation.id)).filter(DetectionValidation.is_valid == True).scalar() or 0
recent_changes = db.query(func.count(InfrastructureChangeLog.id)).filter(
InfrastructureChangeLog.change_date >= (now - timedelta(days=30))
).scalar() or 0
return {
"total_detection_assets": total_assets,
"total_valid_validations": total_valid,
"health_distribution": {k.value if hasattr(k, "value") else str(k): v for k, v in health_dist.items()},
"confidence_distribution": {k.value if hasattr(k, "value") else str(k): v for k, v in confidence_dist.items()},
"validations_expiring_7d": expiring_soon,
"infrastructure_changes_30d": recent_changes,
}