"""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, }