feat(dlm): Phase 8 — Detection Lifecycle Management [FASE-8]
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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>
This commit is contained in:
302
backend/app/routers/detection_lifecycle.py
Normal file
302
backend/app/routers/detection_lifecycle.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""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,
|
||||
}
|
||||
Reference in New Issue
Block a user