# Aegis — Plan de Implementación Consolidado v3.0 **Documento de Referencia para Ralph y Claude Code** **Fecha:** 17 de febrero de 2026 **Alcance:** Deuda técnica + Features nuevas + Transformación a Detection Assurance Platform **Estimación total:** ~42-52 semanas --- ## Visión Aegis evoluciona en tres etapas: 1. **Fundamentos (Fase 0):** Resolver deuda técnica bloqueante 2. **Features Operativas (Fases 1-7):** Integrations, reporting, compliance, inteligencia 3. **Detection Assurance Platform (Fases 8-14):** Transformar Aegis de tracker MITRE a sistema de garantía continua de detección — donde cada detección tiene ciclo de vida, ownership, salud medible, y el sistema orquesta proactivamente la revalidación --- ## Índice de Fases | Fase | Nombre | Duración | Prioridad/Dependencias | |------|--------|----------|----------------------| | 0 | Fundamentos Técnicos (deuda técnica bloqueante) | 2 semanas | CRÍTICA — sin dependencias | | 1 | Integración Jira + Tempo | 3 semanas | Fase 0 (Redis) | | 2 | Motor de Reporting Profesional | 3-4 semanas | Fase 0 (excepciones de dominio) | | 3 | Compliance & Hardening de Seguridad | 2-3 semanas | Fase 0 (audit mejorado) | | 4 | Inteligencia Automática (OSINT + Stale Detection) | 2-3 semanas | Fase 0 (índices BD) | | 5 | Gestión Operativa Avanzada | 2-3 semanas | Fase 0 (scoring en BD) | | 6 | Analytics para BI + Webhooks | 2 semanas | Fase 2 | | 7 | Notificaciones Multi-Canal | 1-2 semanas | Fase 3 | | 8 | Detection Lifecycle Management (DLM) | 3-4 semanas | Fase 0 (Redis, índices, excepciones) | | 9 | Ownership & Operativa Diaria | 2-3 semanas | Fase 8 | | 10 | Attack Paths & Purple Team Avanzado | 3-4 semanas | Fases 8, 9 | | 11 | Knowledge Management | 2-3 semanas | Fase 8 | | 12 | Risk Intelligence & Recomendaciones | 2-3 semanas | Fases 4, 8, 9 | | 13 | Alertas Inteligentes & Integraciones Avanzadas | 2-3 semanas | Fases 6, 8, 9, 12 | | 14 | SSO/SAML & API Keys (Enterprise Readiness) | 2 semanas | Fase 0 | > **Nota sobre paralelismo:** Las Fases 1-7 y la Fase 8 pueden ejecutarse en paralelo tras completar Fase 0, ya que no tienen dependencias cruzadas directas. Sin embargo, se recomienda completar al menos Fases 0-5 antes de iniciar Fase 8 para tener una base operativa sólida. --- ## Mapa de Dependencias ``` Fase 0 (Fundamentos) ───────────────────────────────────────────────── │ │ ├──► Fase 1 (Jira + Tempo) ─── necesita Redis │ │ │ ├──► Fase 2 (Reporting) ────── necesita excepciones de dominio │ │ │ │ │ └──► Fase 6 (Analytics/Webhooks) │ │ │ │ │ └──► Fase 13 (Alertas) ◄── usa webhooks │ │ ▲ como canal │ │ │ │ ├──► Fase 3 (Compliance) ───── necesita audit mejorado │ │ │ │ │ └──► Fase 7 (Notificaciones) │ │ │ ├──► Fase 4 (Intel Auto) ───── necesita índices BD │ │ │ │ │ └──► Fase 12 (Risk) ◄── usa OsintItem │ │ ▲ │ │ │ │ ├──► Fase 5 (Operativa) ────── necesita scoring en BD │ │ │ ├──► Fase 8 (Detection Lifecycle) ───────────────────────────── │ │ │ │ │ ├──► Fase 9 (Ownership) ── necesita decay + confidence │ │ │ │ │ │ │ ├──► Fase 10 (Attack Paths) │ │ │ │ necesita ownership + revalidation │ │ │ │ │ │ │ └──► Fase 12 (Risk & Recomendaciones) │ │ │ necesita confidence + ownership │ │ │ │ │ ├──► Fase 11 (Knowledge Management) ── independiente │ │ │ │ │ └──► Fase 13 (Alertas Inteligentes) │ │ necesita todas las fuentes anteriores │ │ │ └──► Fase 14 (Enterprise/SSO) ── independiente │ ``` > **Nota:** Fase 4 (Stale Detection) implementa una versión simple de detección de obsolescencia. Fase 8 (Decay Engine) la reemplaza con un motor completo y configurable. Fase 4 sirve como stepping stone y puede coexistir — el decay engine de Fase 8 es la versión definitiva. --- ## FASE 0 — Fundamentos Técnicos **Por qué primero:** Las nuevas features (Jira, Tempo, reports, DLM, etc.) necesitan Redis, excepciones de dominio limpias, e índices en BD para no colapsar el sistema. Esta fase resuelve la deuda técnica que bloquea todo lo demás. **Duración estimada:** 2 semanas **Dependencias:** Ninguna --- ### Tarea 0.1: Redis como servicio de infraestructura **Qué:** Añadir un contenedor Redis al stack Docker Compose para token blacklist, cache de scores y futuras colas. **Implementación:** 1. **docker-compose.yml y docker-compose.prod.yml** — Agregar servicio Redis: ```yaml redis: image: redis:7-alpine command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru ports: - "6379:6379" # solo en dev volumes: - aegis_redis_data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 5 restart: always ``` 2. **requirements.txt** — Añadir `redis>=5.0.0` 3. **app/config.py** — Nuevas variables: ```python REDIS_URL: str = "redis://redis:6379/0" REDIS_TOKEN_BLACKLIST_DB: int = 1 REDIS_CACHE_DB: int = 2 ``` 4. **app/infrastructure/redis_client.py** — Cliente singleton: ```python import redis from app.config import settings _redis_client: redis.Redis | None = None def get_redis() -> redis.Redis: global _redis_client if _redis_client is None: _redis_client = redis.from_url( settings.REDIS_URL, decode_responses=True ) return _redis_client ``` 5. **Backend Depends en docker-compose** — Añadir `redis: condition: service_healthy` **Verificación:** - `docker compose up redis` arranca y responde a `redis-cli ping` con `PONG` - Test unitario: conectar, set/get una clave, verificar TTL funciona - Health check Docker pasa en `docker compose ps` --- ### Tarea 0.2: Mover Token Blacklist a Redis (SEC-001) **Qué:** Reemplazar el `set()` en memoria de auth.py por Redis con TTL automático. **Implementación:** 1. **app/auth.py** — Reemplazar: ```python # ANTES (eliminar): _blacklisted_tokens: set[str] = set() # DESPUÉS: from app.infrastructure.redis_client import get_redis def blacklist_token(token: str, expires_in_seconds: int): """Añade token al blacklist con TTL igual a su tiempo restante de expiración.""" r = get_redis() r.setex(f"blacklist:{token}", expires_in_seconds, "1") def is_token_blacklisted(token: str) -> bool: r = get_redis() return r.exists(f"blacklist:{token}") > 0 ``` 2. **Endpoint de logout** — Calcular TTL restante del JWT antes de blacklistear: ```python from jose import jwt import time payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) exp = payload.get("exp", 0) ttl = max(int(exp - time.time()), 0) blacklist_token(token, ttl) ``` 3. **Middleware de verificación** — En `get_current_user`, añadir check: ```python if is_token_blacklisted(token): raise HTTPException(status_code=401, detail="Token has been revoked") ``` **Verificación:** - Login → obtener token → Logout → usar mismo token → debe dar 401 - Reiniciar backend container → token sigue blacklisteado en Redis - Verificar en Redis CLI: `keys blacklist:*` muestra tokens activos - TTL correcto: `ttl blacklist:` debe ser menor al token expiration --- ### Tarea 0.3: Índices de Base de Datos (SR-006) **Qué:** Crear migración Alembic con los índices compuestos faltantes. **Implementación:** 1. Crear nueva migración Alembic: ```bash alembic revision --autogenerate -m "add_composite_indexes" ``` 2. En el archivo de migración generado, agregar los índices manualmente: ```python def upgrade(): # Tests - usado por scoring, heatmap, metrics, reports op.create_index('ix_tests_technique_id_state', 'tests', ['technique_id', 'state']) op.create_index('ix_tests_state_red_validated_at', 'tests', ['state', 'red_validated_at']) op.create_index('ix_tests_remediation_status', 'tests', ['remediation_status']) op.create_index('ix_tests_campaign_id', 'tests', ['campaign_id']) # Techniques op.create_index('ix_techniques_tactic', 'techniques', ['tactic']) op.create_index('ix_techniques_status_global', 'techniques', ['status_global']) # Audit logs - para MTTD/MTTR op.create_index('ix_audit_logs_entity_type_entity_id', 'audit_logs', ['entity_type', 'entity_id', 'action']) op.create_index('ix_audit_logs_timestamp', 'audit_logs', ['timestamp']) op.create_index('ix_audit_logs_user_id', 'audit_logs', ['user_id']) # Detection rules op.create_index('ix_detection_rules_mitre_technique_id', 'detection_rules', ['mitre_technique_id']) # Notifications op.create_index('ix_notifications_user_id_is_read', 'notifications', ['user_id', 'is_read']) def downgrade(): op.drop_index('ix_tests_technique_id_state') op.drop_index('ix_tests_state_red_validated_at') # ... drop todos ``` **Verificación:** - `alembic upgrade head` ejecuta sin error - `\di` en psql muestra todos los nuevos índices - Query `EXPLAIN ANALYZE SELECT * FROM tests WHERE technique_id = '...' AND state = 'validated'` usa el índice (Index Scan, no Seq Scan) --- ### Tarea 0.4: Excepciones de Dominio + Error Handler Middleware (TD-003) **Qué:** Crear excepciones de dominio y un middleware que las mapee a HTTP, para eliminar HTTPException de los servicios. **Implementación:** 1. **app/domain/exceptions.py** (nuevo archivo): ```python class DomainException(Exception): """Base para todas las excepciones de dominio.""" def __init__(self, message: str, code: str = "DOMAIN_ERROR"): self.message = message self.code = code super().__init__(message) class EntityNotFoundError(DomainException): def __init__(self, entity: str, identifier: str): super().__init__(f"{entity} not found: {identifier}", "NOT_FOUND") self.entity = entity self.identifier = identifier class DuplicateEntityError(DomainException): def __init__(self, entity: str, field: str, value: str): super().__init__(f"{entity} with {field}='{value}' already exists", "DUPLICATE") class InvalidTransitionError(DomainException): def __init__(self, current_state: str, target_state: str): super().__init__( f"Cannot transition from '{current_state}' to '{target_state}'", "INVALID_TRANSITION" ) class InvalidOperationError(DomainException): def __init__(self, message: str): super().__init__(message, "INVALID_OPERATION") class AuthorizationError(DomainException): def __init__(self, message: str = "Insufficient permissions"): super().__init__(message, "FORBIDDEN") ``` 2. **app/middleware/error_handler.py** (nuevo): ```python from fastapi import Request from fastapi.responses import JSONResponse from app.domain.exceptions import ( EntityNotFoundError, DuplicateEntityError, InvalidTransitionError, InvalidOperationError, AuthorizationError, DomainException ) EXCEPTION_STATUS_MAP = { EntityNotFoundError: 404, DuplicateEntityError: 409, InvalidTransitionError: 400, InvalidOperationError: 400, AuthorizationError: 403, } async def domain_exception_handler(request: Request, exc: DomainException): status = EXCEPTION_STATUS_MAP.get(type(exc), 400) return JSONResponse( status_code=status, content={"detail": exc.message, "code": exc.code} ) ``` 3. **app/main.py** — Registrar handler: ```python from app.domain.exceptions import DomainException from app.middleware.error_handler import domain_exception_handler app.add_exception_handler(DomainException, domain_exception_handler) ``` 4. **test_workflow_service.py** — Reemplazar HTTPException por excepciones de dominio: ```python # ANTES: from fastapi import HTTPException raise HTTPException(status_code=400, detail={...}) # DESPUÉS: from app.domain.exceptions import InvalidTransitionError raise InvalidTransitionError(current_state=test.state, target_state="red_executing") ``` 5. Repetir para `campaign_service.py` (el otro servicio que importa HTTPException). **Verificación:** - `grep -r "from fastapi import HTTPException" app/services/` devuelve 0 resultados - Tests existentes de workflow siguen pasando (errores 400 se mantienen) - Nuevo test: llamar endpoint con transición inválida → respuesta JSON con `{code: "INVALID_TRANSITION"}` --- ### Tarea 0.5: Arreglar Excepciones Silenciadas (TD-007) **Qué:** Reemplazar `except Exception: pass` por logging en workflow service. **Implementación:** En `test_workflow_service.py`, localizar los 4 bloques `except Exception: pass` y reemplazar: ```python # ANTES: try: notify_test_state_change(...) except Exception: pass # DESPUÉS: import logging logger = logging.getLogger(__name__) try: notify_test_state_change(...) except Exception as e: logger.warning(f"Notification failed for test {test.id}: {e}", exc_info=True) ``` **Verificación:** - `grep -r "except Exception: pass" app/` devuelve 0 resultados - Simular fallo de notificación → ver warning en logs del backend - Workflow sigue funcionando aunque notificación falle --- ### Tarea 0.6: CI/CD Básico con GitHub Actions (TD-009) **Qué:** Pipeline mínimo: lint + tests. **Implementación:** **.github/workflows/ci.yml:** ```yaml name: Aegis CI on: push: branches: [main, develop] pull_request: branches: [main] jobs: lint-and-test: runs-on: ubuntu-latest services: postgres: image: postgres:15-alpine env: POSTGRES_DB: testdb POSTGRES_USER: test POSTGRES_PASSWORD: test ports: ["5432:5432"] options: --health-cmd pg_isready --health-interval 10s redis: image: redis:7-alpine ports: ["6379:6379"] options: --health-cmd "redis-cli ping" --health-interval 10s steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install deps run: | cd backend pip install -r requirements.txt pip install ruff pytest - name: Lint run: cd backend && ruff check app/ - name: Test env: DATABASE_URL: postgresql://test:test@localhost:5432/testdb REDIS_URL: redis://localhost:6379/0 SECRET_KEY: test-secret-key-for-ci run: cd backend && pytest tests/ -v --tb=short ``` **Verificación:** - Push a branch → GitHub Actions ejecuta sin error - Badge verde en README - Lint falla si introduces código mal formateado --- ## FASE 1 — Integración Jira + Tempo **Duración estimada:** 3 semanas **Dependencias:** Fase 0 (Redis) --- ### Tarea 1.1: Modelo de Datos — Tabla de Asociaciones Jira **Qué:** Crear modelo y migración para vincular entidades Aegis con tickets Jira. **Implementación:** 1. **app/models/jira_link.py** (nuevo): ```python import uuid from datetime import datetime from sqlalchemy import Column, String, DateTime, ForeignKey, Enum as SQLEnum, Text from sqlalchemy.dialects.postgresql import UUID, JSONB from sqlalchemy.orm import relationship from app.database import Base import enum class JiraLinkEntityType(str, enum.Enum): test = "test" technique = "technique" campaign = "campaign" evidence = "evidence" class JiraSyncDirection(str, enum.Enum): aegis_to_jira = "aegis_to_jira" jira_to_aegis = "jira_to_aegis" bidirectional = "bidirectional" class JiraLink(Base): __tablename__ = "jira_links" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) entity_type = Column(SQLEnum(JiraLinkEntityType), nullable=False) entity_id = Column(UUID(as_uuid=True), nullable=False, index=True) jira_issue_key = Column(String(50), nullable=False, index=True) # e.g. "SEC-1234" jira_issue_id = Column(String(50)) # Jira internal numeric ID jira_project_key = Column(String(20)) jira_status = Column(String(100)) jira_priority = Column(String(50)) jira_assignee = Column(String(255)) jira_story_points = Column(String(10)) sync_direction = Column(SQLEnum(JiraSyncDirection), default=JiraSyncDirection.bidirectional) last_synced_at = Column(DateTime) sync_metadata = Column(JSONB, default={}) created_by = Column(UUID(as_uuid=True), ForeignKey("users.id")) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) ``` 2. **Alembic migration:** ```bash alembic revision --autogenerate -m "add_jira_links_table" alembic upgrade head ``` 3. **app/schemas/jira_schema.py** (nuevo): ```python from pydantic import BaseModel, Field from typing import Optional from uuid import UUID from datetime import datetime from app.models.jira_link import JiraLinkEntityType, JiraSyncDirection class JiraLinkCreate(BaseModel): entity_type: JiraLinkEntityType entity_id: UUID jira_issue_key: str = Field(..., pattern=r'^[A-Z]+-\d+$') sync_direction: JiraSyncDirection = JiraSyncDirection.bidirectional class JiraLinkOut(BaseModel): id: UUID entity_type: JiraLinkEntityType entity_id: UUID jira_issue_key: str jira_status: Optional[str] jira_priority: Optional[str] jira_assignee: Optional[str] last_synced_at: Optional[datetime] created_at: datetime class Config: from_attributes = True class JiraIssueSearch(BaseModel): query: str class JiraIssueResult(BaseModel): issue_key: str summary: str status: str assignee: Optional[str] priority: Optional[str] ``` **Verificación:** - Migración ejecuta sin error - Tabla `jira_links` existe con todas las columnas e índices - Schema valida que `jira_issue_key` tenga formato `PROJ-123` --- ### Tarea 1.2: Servicio de Integración Jira **Qué:** Servicio que encapsula toda la comunicación con Jira REST API usando `atlassian-python-api`. **Implementación:** 1. **requirements.txt** — Añadir: `atlassian-python-api>=4.0.0` 2. **app/config.py** — Nuevas settings: ```python # Jira Integration JIRA_ENABLED: bool = False JIRA_URL: str = "" JIRA_USERNAME: str = "" JIRA_API_TOKEN: str = "" JIRA_IS_CLOUD: bool = True JIRA_DEFAULT_PROJECT: str = "" JIRA_ISSUE_TYPE_TEST: str = "Task" JIRA_ISSUE_TYPE_CAMPAIGN: str = "Epic" ``` 3. **app/services/jira_service.py** (nuevo): ```python import logging from typing import Optional from atlassian import Jira from app.config import settings from sqlalchemy.orm import Session from app.models.jira_link import JiraLink, JiraLinkEntityType from datetime import datetime logger = logging.getLogger(__name__) _jira_client: Optional[Jira] = None def get_jira_client() -> Jira: global _jira_client if not settings.JIRA_ENABLED: raise InvalidOperationError("Jira integration is not enabled") if _jira_client is None: _jira_client = Jira( url=settings.JIRA_URL, username=settings.JIRA_USERNAME, password=settings.JIRA_API_TOKEN, cloud=settings.JIRA_IS_CLOUD, ) return _jira_client def search_jira_issues(query: str, max_results: int = 10) -> list[dict]: """Busca issues en Jira por JQL o texto.""" jira = get_jira_client() jql = query if " " not in query or "=" in query else f'summary ~ "{query}"' results = jira.jql(jql, limit=max_results) return [ { "issue_key": issue["key"], "summary": issue["fields"]["summary"], "status": issue["fields"]["status"]["name"], "assignee": (issue["fields"].get("assignee") or {}).get("displayName"), "priority": (issue["fields"].get("priority") or {}).get("name"), } for issue in results.get("issues", []) ] def create_jira_issue( project_key: str, summary: str, description: str, issue_type: str = "Task", labels: list[str] = None, custom_fields: dict = None, ) -> dict: """Crea un issue en Jira y retorna key + id.""" jira = get_jira_client() fields = { "project": {"key": project_key}, "summary": summary, "description": description, "issuetype": {"name": issue_type}, } if labels: fields["labels"] = labels if custom_fields: fields.update(custom_fields) result = jira.issue_create(fields=fields) return {"issue_key": result["key"], "issue_id": result["id"]} def sync_aegis_to_jira(db: Session, link: JiraLink, entity_data: dict): """Sincroniza datos de Aegis hacia Jira (añade comentario con resultados).""" jira = get_jira_client() comment_body = _build_sync_comment(entity_data) jira.issue_add_comment(link.jira_issue_key, comment_body) link.last_synced_at = datetime.utcnow() db.commit() def sync_jira_to_aegis(db: Session, link: JiraLink): """Sincroniza estado de Jira hacia Aegis.""" jira = get_jira_client() issue = jira.issue(link.jira_issue_key) link.jira_status = issue["fields"]["status"]["name"] link.jira_priority = (issue["fields"].get("priority") or {}).get("name") link.jira_assignee = (issue["fields"].get("assignee") or {}).get("displayName") link.jira_story_points = issue["fields"].get("customfield_10016") link.last_synced_at = datetime.utcnow() db.commit() def _build_sync_comment(data: dict) -> str: """Genera un comentario formateado para Jira.""" lines = ["h3. Aegis Sync Update", ""] for key, value in data.items(): lines.append(f"*{key}:* {value}") lines.append(f"\n_Synced at {datetime.utcnow().isoformat()}_") return "\n".join(lines) ``` **Verificación:** - Con `JIRA_ENABLED=False`, llamar a `get_jira_client()` lanza `InvalidOperationError` - Con credenciales válidas de test: `search_jira_issues("project = TEST")` retorna issues - `create_jira_issue(...)` crea ticket y retorna key válida - Mock test: verificar que `sync_aegis_to_jira` llama a `issue_add_comment` --- ### Tarea 1.3: Router de Jira Links **Qué:** Endpoints para crear/listar/sincronizar asociaciones Jira. **Implementación:** **app/routers/jira.py** (nuevo): ```python from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session from uuid import UUID from typing import Optional from app.database import get_db from app.dependencies.auth import get_current_user, require_role from app.models.jira_link import JiraLink, JiraLinkEntityType from app.schemas.jira_schema import ( JiraLinkCreate, JiraLinkOut, JiraIssueSearch, JiraIssueResult ) from app.services import jira_service, audit_service router = APIRouter(prefix="/jira", tags=["jira"]) @router.get("/search", response_model=list[JiraIssueResult]) def search_issues( q: str = Query(..., min_length=2), max_results: int = Query(10, le=50), user=Depends(get_current_user), ): """Buscar issues en Jira por JQL o texto.""" return jira_service.search_jira_issues(q, max_results) @router.post("/links", response_model=JiraLinkOut, status_code=201) def create_link( body: JiraLinkCreate, db: Session = Depends(get_db), user=Depends(get_current_user), ): """Asociar una entidad Aegis con un ticket Jira.""" link = JiraLink( entity_type=body.entity_type, entity_id=body.entity_id, jira_issue_key=body.jira_issue_key, sync_direction=body.sync_direction, created_by=user.id, ) jira_service.sync_jira_to_aegis(db, link) db.add(link) db.commit() db.refresh(link) audit_service.log_action(db, user.id, "JIRA_LINK_CREATED", "jira_link", str(link.id), details={"entity_type": body.entity_type, "issue_key": body.jira_issue_key}) return link @router.get("/links", response_model=list[JiraLinkOut]) def list_links( entity_type: Optional[JiraLinkEntityType] = None, entity_id: Optional[UUID] = None, db: Session = Depends(get_db), user=Depends(get_current_user), ): """Listar asociaciones Jira, filtrando opcionalmente por entidad.""" query = db.query(JiraLink) if entity_type: query = query.filter(JiraLink.entity_type == entity_type) if entity_id: query = query.filter(JiraLink.entity_id == entity_id) return query.order_by(JiraLink.created_at.desc()).all() @router.post("/links/{link_id}/sync") def sync_link( link_id: UUID, db: Session = Depends(get_db), user=Depends(require_role("admin")), ): """Forzar sincronización bidireccional de un link.""" link = db.query(JiraLink).filter(JiraLink.id == link_id).first() if not link: raise EntityNotFoundError("JiraLink", str(link_id)) jira_service.sync_jira_to_aegis(db, link) return {"message": "Sync completed", "jira_status": link.jira_status} @router.post("/create-issue") def create_issue_from_entity( entity_type: JiraLinkEntityType, entity_id: UUID, db: Session = Depends(get_db), user=Depends(get_current_user), ): """Auto-crear un ticket Jira desde una entidad Aegis.""" summary, description = _build_issue_data(db, entity_type, entity_id) result = jira_service.create_jira_issue( project_key=settings.JIRA_DEFAULT_PROJECT, summary=summary, description=description, labels=["aegis", entity_type.value], ) link = JiraLink( entity_type=entity_type, entity_id=entity_id, jira_issue_key=result["issue_key"], jira_issue_id=result["issue_id"], jira_project_key=settings.JIRA_DEFAULT_PROJECT, created_by=user.id, ) db.add(link) db.commit() return {"issue_key": result["issue_key"], "link_id": str(link.id)} ``` Registrar en main.py: `app.include_router(jira_router, prefix="/api/v1")` **Verificación:** - `POST /api/v1/jira/links` con issue_key inválido (sin formato) → 422 - `POST /api/v1/jira/links` con issue_key válido → 201 + datos de Jira populados - `GET /api/v1/jira/links?entity_type=test&entity_id=...` → lista filtrada - Audit log registra la acción `JIRA_LINK_CREATED` --- ### Tarea 1.4: Servicio de Integración Tempo **Qué:** Servicio para registrar worklogs automáticos en Tempo desde tests completados. **Implementación:** 1. **requirements.txt** — Añadir: `tempo-api-python-client>=0.8.0` 2. **app/config.py** — Nuevas settings: ```python TEMPO_ENABLED: bool = False TEMPO_API_TOKEN: str = "" TEMPO_API_VERSION: int = 4 TEMPO_DEFAULT_WORK_TYPE: str = "Red Team" ``` 3. **app/services/tempo_service.py** (nuevo): ```python import logging from typing import Optional from tempoapiclient import client_v4 as tempo_client from app.config import settings from sqlalchemy.orm import Session from app.models.jira_link import JiraLink logger = logging.getLogger(__name__) def get_tempo_client(): if not settings.TEMPO_ENABLED: raise InvalidOperationError("Tempo integration is not enabled") return tempo_client.Tempo(auth_token=settings.TEMPO_API_TOKEN) def log_worklog( jira_issue_id: int, author_account_id: str, date: str, time_spent_seconds: int, description: str, work_type: str = None, ) -> dict: """Registra un worklog en Tempo.""" tempo = get_tempo_client() worklog = tempo.create_worklog( accountId=author_account_id, issueId=jira_issue_id, dateFrom=date, timeSpentSeconds=time_spent_seconds, description=description, ) return worklog def auto_log_test_worklog(db: Session, test, user, activity_type: str): """Si el test tiene un Jira link, registrar tiempo automáticamente en Tempo.""" if not settings.TEMPO_ENABLED: return link = db.query(JiraLink).filter( JiraLink.entity_id == test.id, JiraLink.entity_type == "test" ).first() if not link or not link.jira_issue_id: logger.debug(f"No Jira link for test {test.id}, skipping Tempo worklog") return duration = _calculate_duration(test, activity_type) if duration <= 0: return try: log_worklog( jira_issue_id=int(link.jira_issue_id), author_account_id=user.jira_account_id or "", date=test.updated_at.strftime("%Y-%m-%d"), time_spent_seconds=duration, description=f"[Aegis] {activity_type}: {test.name}", ) logger.info(f"Tempo worklog created for test {test.id}, {duration}s") except Exception as e: logger.warning(f"Tempo worklog failed for test {test.id}: {e}") ``` **Verificación:** - Con `TEMPO_ENABLED=False`, la función retorna silenciosamente - Mock test: verificar que `create_worklog` se llama con parámetros correctos - Integración: crear worklog en Tempo sandbox y verificar que aparece --- ### Tarea 1.5: Worklog Interno Auditado **Qué:** Tabla de registro de tiempo interno e inmutable, previo a envío a Tempo. **Implementación:** 1. **app/models/worklog.py** (nuevo): ```python import uuid from datetime import datetime from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, Text from sqlalchemy.dialects.postgresql import UUID, JSONB from app.database import Base class Worklog(Base): __tablename__ = "worklogs" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) entity_type = Column(String(50), nullable=False) entity_id = Column(UUID(as_uuid=True), nullable=False, index=True) user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) activity_type = Column(String(100), nullable=False) started_at = Column(DateTime, nullable=False) ended_at = Column(DateTime) duration_seconds = Column(Integer, nullable=False) description = Column(Text) tempo_synced = Column(DateTime) tempo_worklog_id = Column(String(100)) integrity_hash = Column(String(64)) created_at = Column(DateTime, default=datetime.utcnow) metadata = Column(JSONB, default={}) ``` 2. **app/services/worklog_service.py** — funciones CRUD + cálculo de hash de integridad: ```python import hashlib from datetime import datetime from sqlalchemy.orm import Session from app.models.worklog import Worklog def create_worklog(db: Session, **kwargs) -> Worklog: wl = Worklog(**kwargs) wl.integrity_hash = _compute_hash(wl) db.add(wl) db.commit() db.refresh(wl) return wl def _compute_hash(wl: Worklog) -> str: data = f"{wl.entity_type}:{wl.entity_id}:{wl.user_id}:{wl.activity_type}:{wl.started_at}:{wl.duration_seconds}" return hashlib.sha256(data.encode()).hexdigest() def verify_worklog_integrity(wl: Worklog) -> bool: return wl.integrity_hash == _compute_hash(wl) ``` 3. **app/routers/worklogs.py** — Endpoints para consultar y crear worklogs manuales. **Verificación:** - Crear worklog → `integrity_hash` se genera automáticamente - Modificar un campo en DB directo → `verify_worklog_integrity()` retorna `False` - Listar worklogs por entidad funciona con filtros --- ### Tarea 1.6: Frontend — Componente JiraLink + Tempo **Qué:** Componente React para vincular entidades con Jira y ver worklogs. **Implementación:** - `src/api/jira.ts` — funciones API (search, createLink, listLinks, syncLink) - `src/components/JiraLinkPanel.tsx` — Panel con buscar, vincular y ver estado del ticket - `src/components/WorklogTimeline.tsx` — Timeline de worklogs con duración y tipo - Integrar JiraLinkPanel en páginas de Test Detail, Campaign Detail y Technique Detail - `src/types/models.ts` — Añadir tipos JiraLink, Worklog, etc. **Verificación:** - En Test Detail, el panel de Jira aparece y permite buscar issues - Vincular un issue muestra su estado actualizado - Timeline de worklogs muestra registro cronológico --- ### Tarea 1.7: Job de Sincronización Jira Automática **Qué:** Job APScheduler que sincroniza el estado de Jira links cada hora. **Implementación:** En `jobs/jira_sync_job.py`: ```python from app.database import SessionLocal from app.models.jira_link import JiraLink from app.services import jira_service from app.config import settings import logging logger = logging.getLogger(__name__) def sync_all_jira_links(): if not settings.JIRA_ENABLED: return db = SessionLocal() try: links = db.query(JiraLink).all() synced = 0 for link in links: try: jira_service.sync_jira_to_aegis(db, link) synced += 1 except Exception as e: logger.warning(f"Jira sync failed for link {link.id}: {e}") logger.info(f"Jira sync completed: {synced}/{len(links)} links updated") finally: db.close() ``` Registrar en scheduler: `scheduler.add_job(sync_all_jira_links, "interval", hours=1, replace_existing=True)` **Verificación:** - Job aparece en logs al arrancar el backend - Tras 1 hora (o trigger manual), los links muestran `last_synced_at` actualizado - Links con issues borrados en Jira se logean como warning sin crashear --- ## FASE 2 — Motor de Reporting Profesional **Duración estimada:** 3-4 semanas **Dependencias:** Fase 0 (excepciones de dominio) --- ### Tarea 2.1: Motor de Plantillas — Backend **Qué:** Sistema de plantillas Jinja2 que genera HTML, que luego se convierte a PDF con WeasyPrint y a DOCX con docxtpl. **Implementación:** 1. **requirements.txt** — Añadir: ``` weasyprint>=62.0 docxtpl>=0.18.0 Jinja2>=3.1.0 # (ya instalado por FastAPI) ``` 2. **app/config.py:** ```python REPORT_TEMPLATES_DIR: str = "app/templates/reports" REPORT_OUTPUT_DIR: str = "/tmp/aegis_reports" COMPANY_NAME: str = "Organization" COMPANY_LOGO_PATH: str = "app/templates/reports/assets/logo.png" ``` 3. **app/services/report_engine.py** (nuevo): ```python import os import uuid from datetime import datetime from jinja2 import Environment, FileSystemLoader from weasyprint import HTML, CSS from docxtpl import DocxTemplate from app.config import settings class ReportEngine: def __init__(self): self.jinja_env = Environment( loader=FileSystemLoader(settings.REPORT_TEMPLATES_DIR), autoescape=True ) os.makedirs(settings.REPORT_OUTPUT_DIR, exist_ok=True) def render_html(self, template_name: str, context: dict) -> str: template = self.jinja_env.get_template(f"{template_name}.html") context["company_name"] = settings.COMPANY_NAME context["generated_at"] = datetime.utcnow().isoformat() return template.render(context) def generate_pdf(self, template_name: str, context: dict) -> str: html_content = self.render_html(template_name, context) css_path = os.path.join(settings.REPORT_TEMPLATES_DIR, "styles", "report.css") output_path = os.path.join( settings.REPORT_OUTPUT_DIR, f"{template_name}_{uuid.uuid4().hex[:8]}.pdf" ) css = CSS(filename=css_path) if os.path.exists(css_path) else None stylesheets = [css] if css else [] HTML(string=html_content, base_url=settings.REPORT_TEMPLATES_DIR).write_pdf( output_path, stylesheets=stylesheets ) return output_path def generate_docx(self, template_name: str, context: dict) -> str: template_path = os.path.join(settings.REPORT_TEMPLATES_DIR, f"{template_name}.docx") output_path = os.path.join( settings.REPORT_OUTPUT_DIR, f"{template_name}_{uuid.uuid4().hex[:8]}.docx" ) doc = DocxTemplate(template_path) context["company_name"] = settings.COMPANY_NAME context["generated_at"] = datetime.utcnow().strftime("%B %d, %Y") doc.render(context) doc.save(output_path) return output_path def generate_html(self, template_name: str, context: dict) -> str: html_content = self.render_html(template_name, context) output_path = os.path.join( settings.REPORT_OUTPUT_DIR, f"{template_name}_{uuid.uuid4().hex[:8]}.html" ) with open(output_path, "w") as f: f.write(html_content) return output_path report_engine = ReportEngine() ``` 4. **Estructura de plantillas:** ``` app/templates/reports/ ├── styles/ │ └── report.css ├── assets/ │ └── logo.png ├── purple_campaign.html ├── purple_campaign.docx ├── coverage_report.html ├── technique_detail.html ├── quarterly_summary.html └── executive_summary.html ``` **Verificación:** - `report_engine.generate_pdf("coverage_report", sample_context)` genera PDF legible - `report_engine.generate_docx("purple_campaign", sample_context)` genera DOCX válido - CSS se aplica correctamente (colores corporativos, headers, page breaks) --- ### Tarea 2.2: Plantilla de Informe Purple Team **Qué:** La plantilla HTML Jinja2 para informes de campaña Purple Team. **Implementación:** **app/templates/reports/purple_campaign.html:** ```html

Purple Team Assessment Report

{{ campaign.name }}

{{ generated_at }}

{{ classification | default('INTERNAL') }}

Table of Contents

1. Executive Summary

Campaign {{ campaign.name }} tested {{ tests | length }} techniques across {{ tactics | length }} tactics. Overall coverage score: {{ org_score }}%.

{{ tests_validated }} Validated
{{ tests_detected }} Detected
{{ tests_not_detected }} Not Detected

2. Scope & Methodology

{{ campaign.description }}

Period: {{ campaign.start_date }} – {{ campaign.end_date }}

Threat actors modeled: {% for actor in threat_actors %}{{ actor.name }}{% if not loop.last %}, {% endif %}{% endfor %}

3. Techniques Tested

{% for test in tests %} {% endfor %}
MITRE IDNameTactic ResultDetection
{{ test.technique_mitre_id }} {{ test.name }} {{ test.tactic }} {{ test.state }} {{ test.detection_result }}

4. Critical Findings

{% for finding in critical_findings %}

{{ finding.technique_id }}: {{ finding.name }}

{{ finding.description }}

Recommendation: {{ finding.recommendation }}

{% endfor %}

5. Coverage Evolution

{% if previous_campaign %}

Compared to previous campaign ({{ previous_campaign.name }}): Coverage changed from {{ previous_score }}% to {{ org_score }}%.

{% endif %}
``` **Verificación:** - Generar PDF con datos de campaña real → todas las secciones se renderizan - Tabla de técnicas muestra colores según resultado (rojo/verde/amarillo) - Page breaks funcionan entre secciones --- ### Tarea 2.3: Servicio de Generación de Reportes **Qué:** Servicio que recopila datos del dominio y llama al ReportEngine. **Implementación:** **app/services/report_generation_service.py** (nuevo): ```python from sqlalchemy.orm import Session from app.services.report_engine import report_engine from app.services import scoring_service from app.models import Campaign, CampaignTest, Test, Technique, ThreatActor def generate_purple_campaign_report( db: Session, campaign_id: str, output_format: str = "pdf" ) -> str: """Genera el informe completo de una campaña Purple Team.""" campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() if not campaign: raise EntityNotFoundError("Campaign", campaign_id) campaign_tests = ( db.query(Test) .join(CampaignTest) .filter(CampaignTest.campaign_id == campaign_id) .all() ) tests_data = [] for test in campaign_tests: technique = db.query(Technique).filter(Technique.id == test.technique_id).first() tests_data.append({ "technique_mitre_id": technique.mitre_id if technique else "N/A", "name": test.name, "tactic": technique.tactic if technique else "N/A", "state": test.state.value if test.state else "draft", "detection_result": test.detection_result.value if test.detection_result else "pending", }) validated = [t for t in campaign_tests if t.state and t.state.value == "validated"] detected = [t for t in validated if t.detection_result and t.detection_result.value == "detected"] not_detected = [t for t in validated if t.detection_result and t.detection_result.value == "not_detected"] critical_findings = [ { "technique_id": t.get("technique_mitre_id"), "name": t.get("name"), "severity": "critical", "description": f"Technique not detected during campaign execution.", "recommendation": "Implement detection rule or review existing SIEM rules.", } for t in tests_data if t["detection_result"] == "not_detected" ] context = { "campaign": campaign, "tests": tests_data, "tests_validated": len(validated), "tests_detected": len(detected), "tests_not_detected": len(not_detected), "critical_findings": critical_findings, "org_score": scoring_service.calculate_organization_score(db).get("overall", 0), "tactics": list(set(t.get("tactic") for t in tests_data)), "threat_actors": [], # TODO: poblar } if output_format == "pdf": return report_engine.generate_pdf("purple_campaign", context) elif output_format == "docx": return report_engine.generate_docx("purple_campaign", context) else: return report_engine.generate_html("purple_campaign", context) ``` --- ### Tarea 2.4: Router de Reportes Profesionales **Implementación:** **app/routers/professional_reports.py** (nuevo): ```python from fastapi import APIRouter, Depends, Query from fastapi.responses import FileResponse from sqlalchemy.orm import Session from uuid import UUID from app.database import get_db from app.dependencies.auth import get_current_user, require_role from app.services import report_generation_service router = APIRouter(prefix="/reports/generate", tags=["professional-reports"]) @router.get("/purple-campaign/{campaign_id}") def generate_purple_report( campaign_id: UUID, format: str = Query("pdf", regex="^(pdf|docx|html)$"), db: Session = Depends(get_db), user=Depends(require_role("red_lead", "blue_lead", "admin")), ): filepath = report_generation_service.generate_purple_campaign_report( db, str(campaign_id), output_format=format ) media_types = { "pdf": "application/pdf", "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "html": "text/html" } return FileResponse(filepath, media_type=media_types[format], filename=f"purple_report.{format}") @router.get("/coverage-summary") def generate_coverage_report( format: str = Query("pdf", regex="^(pdf|docx|html)$"), db: Session = Depends(get_db), user=Depends(get_current_user), ): filepath = report_generation_service.generate_coverage_report(db, output_format=format) media_types = { "pdf": "application/pdf", "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "html": "text/html" } return FileResponse(filepath, media_type=media_types[format], filename=f"coverage_report.{format}") # Similares para: quarterly_summary, technique_detail, executive_summary ``` **Verificación:** - `GET /api/v1/reports/generate/purple-campaign/{id}?format=pdf` → descarga PDF - `GET /api/v1/reports/generate/purple-campaign/{id}?format=docx` → descarga DOCX - PDF tiene portada, tabla de contenidos, datos de tests, hallazgos - DOCX se abre correctamente en Word/LibreOffice --- ### Tarea 2.5: Endpoints de Analytics para PowerBI **Qué:** Endpoints JSON limpios, sin lógica de presentación, optimizados para consumo BI. **Implementación:** **app/routers/analytics.py** (nuevo): ```python router = APIRouter(prefix="/analytics", tags=["analytics"]) @router.get("/coverage") def analytics_coverage(db: Session = Depends(get_db), user=Depends(get_current_user)): """Cobertura por táctica, plataforma y estado — formato plano para BI.""" techniques = db.query(Technique).all() return [ { "mitre_id": t.mitre_id, "name": t.name, "tactic": t.tactic, "status": t.status_global.value if t.status_global else "not_evaluated", "test_count": len(t.tests) if t.tests else 0, "review_required": t.review_required, "last_review_date": t.last_review_date.isoformat() if t.last_review_date else None, } for t in techniques ] @router.get("/tests") def analytics_tests( date_from: str = Query(None), date_to: str = Query(None), db: Session = Depends(get_db), user=Depends(get_current_user) ): """Todos los tests con timestamps — formato plano para BI.""" query = db.query(Test) if date_from: query = query.filter(Test.created_at >= date_from) if date_to: query = query.filter(Test.created_at <= date_to) tests = query.all() return [ { "id": str(t.id), "technique_id": str(t.technique_id), "name": t.name, "state": t.state.value if t.state else None, "detection_result": t.detection_result.value if t.detection_result else None, "created_at": t.created_at.isoformat() if t.created_at else None, "platform": t.platform, "tool_used": t.tool_used, } for t in tests ] @router.get("/trends") def analytics_trends(db: Session = Depends(get_db), user=Depends(get_current_user)): """Snapshots históricos de cobertura para visualización de tendencias.""" snapshots = db.query(CoverageSnapshot).order_by(CoverageSnapshot.created_at).all() return [ { "date": s.created_at.isoformat(), "total_techniques": s.total_techniques, "validated_count": s.validated_count, "coverage_percentage": s.coverage_percentage, "org_score": s.org_score, } for s in snapshots ] @router.get("/operators") def analytics_operators(db: Session = Depends(get_db), user=Depends(require_role("admin"))): """Métricas por operador — para gestión de carga.""" from sqlalchemy import func results = ( db.query( User.username, User.role, func.count(Test.id).label("test_count"), ) .outerjoin(Test, Test.created_by == User.id) .group_by(User.id) .all() ) return [{"username": r[0], "role": r[1], "test_count": r[2]} for r in results] ``` **Verificación:** - Cada endpoint retorna JSON plano (no anidado más de 1 nivel) - Filtros de fecha funcionan correctamente - Datos pueden importarse directamente en PowerBI desde URL - Sin paginación (datasets completos para BI) --- ### Tarea 2.6: Métricas Avanzadas **Qué:** Añadir endpoints de métricas avanzadas: % cobertura por táctica, técnicas nunca probadas, tiempo medio de validación, heatmap histórico. **Implementación:** Extender `app/routers/metrics.py` o crear `app/routers/advanced_metrics.py`: ```python @router.get("/coverage-by-tactic") def coverage_by_tactic(db: Session = Depends(get_db), user=Depends(get_current_user)): """% de cobertura desglosado por táctica MITRE.""" from sqlalchemy import func, case results = ( db.query( Technique.tactic, func.count(Technique.id).label("total"), func.sum(case((Technique.status_global == "validated", 1), else_=0)).label("validated"), ) .group_by(Technique.tactic) .all() ) return [ { "tactic": r[0], "total": r[1], "validated": r[2], "coverage_pct": round((r[2] / r[1]) * 100, 1) if r[1] > 0 else 0, } for r in results ] @router.get("/never-tested") def never_tested_techniques(db: Session = Depends(get_db), user=Depends(get_current_user)): """Técnicas que nunca han sido probadas.""" techniques = ( db.query(Technique) .filter(~Technique.id.in_(db.query(Test.technique_id).distinct())) .order_by(Technique.mitre_id) .all() ) return [{"mitre_id": t.mitre_id, "name": t.name, "tactic": t.tactic} for t in techniques] @router.get("/avg-validation-time") def avg_validation_time(db: Session = Depends(get_db), user=Depends(get_current_user)): """Tiempo medio desde creación del test hasta validación.""" # Implementar con audit log queries optimizadas (batch) pass ``` **Verificación:** - `/coverage-by-tactic` retorna una fila por táctica con porcentaje - `/never-tested` lista técnicas sin ningún test asociado - Datos son consistentes con lo que muestra el heatmap existente --- ## FASE 3 — Compliance & Hardening de Seguridad **Duración estimada:** 2-3 semanas **Dependencias:** Fase 0 (audit mejorado) --- ### Tarea 3.1: Audit Trail Mejorado (IP, Hash de Integridad) **Qué:** Ampliar el audit log con IP del request, hash de integridad y campos adicionales. **Implementación:** 1. **Migración Alembic** — Añadir columnas a `audit_logs`: ```python def upgrade(): op.add_column('audit_logs', Column('ip_address', String(45))) op.add_column('audit_logs', Column('user_agent', String(500))) op.add_column('audit_logs', Column('integrity_hash', String(64))) op.add_column('audit_logs', Column('session_id', String(100))) ``` 2. **Middleware de request context** — Capturar IP: ```python # app/middleware/request_context.py from starlette.middleware.base import BaseHTTPMiddleware from contextvars import ContextVar request_ip: ContextVar[str] = ContextVar("request_ip", default="") request_user_agent: ContextVar[str] = ContextVar("request_user_agent", default="") class RequestContextMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, call_next): ip = request.client.host if request.client else "unknown" forwarded = request.headers.get("X-Forwarded-For") if forwarded: ip = forwarded.split(",")[0].strip() request_ip.set(ip) request_user_agent.set(request.headers.get("User-Agent", "")) response = await call_next(request) return response ``` 3. **audit_service.py** — Incorporar IP y hash: ```python import hashlib from app.middleware.request_context import request_ip, request_user_agent def log_action(db, user_id, action, entity_type, entity_id, details=None): ip = request_ip.get("") ua = request_user_agent.get("") entry = AuditLog( user_id=user_id, action=action, entity_type=entity_type, entity_id=entity_id, details=details or {}, ip_address=ip, user_agent=ua, ) data = f"{user_id}:{action}:{entity_type}:{entity_id}:{entry.timestamp.isoformat()}" entry.integrity_hash = hashlib.sha256(data.encode()).hexdigest() db.add(entry) # NO commit aquí — deja que el caller lo haga ``` **Verificación:** - Crear un test → audit log tiene IP y user agent del request - `integrity_hash` no es null en nuevos registros - Modificar un audit log en BD directo → recalcular hash no coincide --- ### Tarea 3.2: Login Attempt Auditing (SEC-009) **Qué:** Registrar intentos de login exitosos y fallidos. **Implementación:** En el router `auth.py`, después de cada intento de login: ```python @router.post("/login") def login(body: LoginRequest, request: Request, db: Session = Depends(get_db)): user = db.query(User).filter(User.username == body.username).first() # SIEMPRE ejecutar bcrypt para evitar timing attack (SEC-005) dummy_hash = "$2b$12$LJ3m4ys3Lg7E3cDpBH0pVe8y2V3hZ2n1KX3X5X5X5X5X5X5X5X" target_hash = user.hashed_password if user else dummy_hash password_valid = verify_password(body.password, target_hash) ip = request.client.host if request.client else "unknown" if not user or not password_valid: audit_service.log_action( db, user.id if user else None, "LOGIN_FAILED", "auth", None, details={"username": body.username, "ip": ip, "reason": "invalid_credentials"} ) db.commit() raise HTTPException(400, detail="Incorrect credentials") audit_service.log_action( db, user.id, "LOGIN_SUCCESS", "auth", str(user.id), details={"ip": ip} ) token = create_access_token({"sub": user.username}) # ... resto del login ``` **Verificación:** - Login fallido → registro en `audit_logs` con `action="LOGIN_FAILED"` - Login exitoso → registro con `action="LOGIN_SUCCESS"` - Ambos incluyen IP - Timing de respuesta similar para usuario existente vs no existente --- ### Tarea 3.3: Validación de Password y Username (SEC-004, SEC-007) **Implementación:** En `app/schemas/user_schema.py`: ```python from pydantic import BaseModel, field_validator import re RESERVED_USERNAMES = {"admin", "system", "root", "aegis", "api", "null", "undefined"} class UserCreate(BaseModel): username: str password: str email: str role: str @field_validator("username") @classmethod def validate_username(cls, v): if len(v) < 3 or len(v) > 50: raise ValueError("Username must be 3-50 characters") if not re.match(r'^[a-zA-Z0-9_-]+$', v): raise ValueError("Username may only contain letters, numbers, hyphens, underscores") if v.lower() in RESERVED_USERNAMES: raise ValueError(f"Username '{v}' is reserved") return v @field_validator("password") @classmethod def validate_password(cls, v): if len(v) < 10: raise ValueError("Password must be at least 10 characters") if not re.search(r'[A-Z]', v): raise ValueError("Password must contain at least one uppercase letter") if not re.search(r'[a-z]', v): raise ValueError("Password must contain at least one lowercase letter") if not re.search(r'[0-9]', v): raise ValueError("Password must contain at least one digit") return v ``` **Verificación:** - `POST /users` con password "123" → 422 con mensaje claro - Username `"../admin"` → 422 - Username `"system"` → 422 (reservado) - Password `"ValidPass123"` → acepta --- ### Tarea 3.4: Rate Limiting Extendido (SEC-003) **Implementación:** En `main.py` y routers relevantes: ```python from slowapi import Limiter from slowapi.util import get_remote_address limiter = Limiter(key_func=get_remote_address) # En routers de sync/import: @router.post("/system/sync-mitre") @limiter.limit("2/hour") def sync_mitre(request: Request, ...): ... @router.post("/system/import-atomic-tests") @limiter.limit("2/hour") def import_atomic(request: Request, ...): ... # En routers de escritura: @router.post("/tests") @limiter.limit("30/minute") def create_test(request: Request, ...): ... # En routers de upload: @router.post("/tests/{id}/evidence") @limiter.limit("10/minute") def upload_evidence(request: Request, ...): ... # En reports (costosos): @router.get("/reports/generate/{type}") @limiter.limit("5/minute") def generate_report(request: Request, ...): ... ``` **Verificación:** - Más de 2 syncs/hora → 429 Too Many Requests - Más de 30 tests/min → 429 - Headers de response incluyen `X-RateLimit-*` --- ### Tarea 3.5: Clasificación de Datos y Retención **Qué:** Añadir campo de clasificación a entidades y políticas de retención. **Implementación:** 1. **Migración:** ```python class DataClassification(str, enum.Enum): public = "public" internal = "internal" sensitive = "sensitive" restricted = "restricted" # Añadir a tablas: tests, evidence, campaigns op.add_column('tests', Column('data_classification', String(20), default='internal')) op.add_column('evidence', Column('data_classification', String(20), default='internal')) op.add_column('campaigns', Column('data_classification', String(20), default='internal')) ``` 2. **Job de retención** — Limpieza automática según política: ```python def apply_retention_policies(): """Job diario que aplica políticas de retención.""" db = SessionLocal() try: cutoff = datetime.utcnow() - timedelta(days=730) deleted = db.query(AuditLog).filter(AuditLog.timestamp < cutoff).delete() logger.info(f"Retention: deleted {deleted} audit logs older than 2 years") db.commit() finally: db.close() ``` **Verificación:** - Tests nuevos tienen `data_classification = 'internal'` por defecto - Admin puede cambiar clasificación de un test - Job de retención elimina logs antiguos sin error --- ## FASE 4 — Inteligencia Automática **Duración estimada:** 2-3 semanas **Dependencias:** Fase 0 (índices BD) > **Nota:** La detección de stale coverage de esta fase (Tarea 4.2) es una versión simplificada que será reemplazada por el Decay Engine completo de Fase 8. Sirve como stepping stone funcional. --- ### Tarea 4.1: Enriquecimiento OSINT por Técnica **Qué:** Para cada técnica, buscar automáticamente blogs, PoCs y CVEs relacionados. **Implementación:** 1. **Modelo — OsintItem:** ```python class OsintItem(Base): __tablename__ = "osint_items" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) technique_id = Column(UUID(as_uuid=True), ForeignKey("techniques.id"), index=True) source_type = Column(String(50)) # "cve", "blog", "poc", "advisory" source_url = Column(Text) title = Column(String(500)) description = Column(Text) severity = Column(String(20)) discovered_at = Column(DateTime, default=datetime.utcnow) reviewed = Column(Boolean, default=False) metadata = Column(JSONB, default={}) ``` 2. **app/services/osint_enrichment_service.py:** ```python import requests import logging from sqlalchemy.orm import Session from app.models.osint_item import OsintItem from app.models.technique import Technique logger = logging.getLogger(__name__) NVD_API_BASE = "https://services.nvd.nist.gov/rest/json/cves/2.0" def enrich_technique_with_cves(db: Session, technique: Technique): """Buscar CVEs relacionados via NVD API usando CAPEC→CWE→CVE mapping.""" try: params = { "keywordSearch": technique.name, "resultsPerPage": 10, } resp = requests.get(NVD_API_BASE, params=params, timeout=30) if resp.status_code != 200: logger.warning(f"NVD API error for {technique.mitre_id}: {resp.status_code}") return 0 data = resp.json() count = 0 for vuln in data.get("vulnerabilities", []): cve = vuln.get("cve", {}) cve_id = cve.get("id") exists = db.query(OsintItem).filter( OsintItem.technique_id == technique.id, OsintItem.source_url.contains(cve_id) ).first() if exists: continue descriptions = cve.get("descriptions", []) desc = next((d["value"] for d in descriptions if d["lang"] == "en"), "") metrics = cve.get("metrics", {}) cvss = metrics.get("cvssMetricV31", [{}])[0] if metrics.get("cvssMetricV31") else {} severity = cvss.get("cvssData", {}).get("baseSeverity", "UNKNOWN") if cvss else "UNKNOWN" item = OsintItem( technique_id=technique.id, source_type="cve", source_url=f"https://nvd.nist.gov/vuln/detail/{cve_id}", title=cve_id, description=desc[:500], severity=severity, metadata={"cvss_score": cvss.get("cvssData", {}).get("baseScore") if cvss else None}, ) db.add(item) count += 1 if count > 0: technique.review_required = True db.commit() logger.info(f"Added {count} CVEs for {technique.mitre_id}") return count except Exception as e: logger.error(f"OSINT enrichment failed for {technique.mitre_id}: {e}") return 0 def enrich_all_techniques(db: Session): """Enriquecer todas las técnicas (rate-limited por NVD: 5 req/30s sin API key).""" import time techniques = db.query(Technique).all() total = 0 for i, tech in enumerate(techniques): total += enrich_technique_with_cves(db, tech) if i % 5 == 4: time.sleep(30) return total ``` 3. **Job semanal:** ```python scheduler.add_job( lambda: enrich_all_techniques(SessionLocal()), "interval", days=7, id="osint_enrichment", replace_existing=True ) ``` **Verificación:** - Ejecutar `enrich_technique_with_cves(db, technique)` para T1059 → obtiene CVEs - No se crean duplicados si se ejecuta dos veces - Técnicas con nuevos CVEs se marcan `review_required = True` - Rate limiting respeta NVD API limits (5 req/30s) --- ### Tarea 4.2: Detección de Stale Coverage (versión simple) **Qué:** Marcar técnicas cuya última prueba fue hace más de N meses. > **Nota:** Esta es la versión simple. Fase 8 (Decay Engine) la reemplaza con un motor completo y configurable con políticas, factores múltiples y confidence scores. **Implementación:** ```python # app/services/stale_detection_service.py from datetime import datetime, timedelta from sqlalchemy.orm import Session from sqlalchemy import func from app.models import Technique, Test import logging logger = logging.getLogger(__name__) STALE_THRESHOLD_DAYS = 365 def detect_stale_coverage(db: Session) -> int: """Marcar técnicas con cobertura stale.""" cutoff = datetime.utcnow() - timedelta(days=STALE_THRESHOLD_DAYS) latest_test = ( db.query( Test.technique_id, func.max(Test.updated_at).label("last_tested") ) .filter(Test.state == "validated") .group_by(Test.technique_id) .subquery() ) stale_techniques = ( db.query(Technique) .outerjoin(latest_test, Technique.id == latest_test.c.technique_id) .filter( (latest_test.c.last_tested < cutoff) | (latest_test.c.last_tested.is_(None)) ) .filter(Technique.status_global != "not_evaluated") .all() ) count = 0 for tech in stale_techniques: if not tech.review_required: tech.review_required = True count += 1 logger.info(f"Marked {tech.mitre_id} as stale coverage") if count > 0: db.commit() return count ``` Registrar como job diario. **Verificación:** - Técnica con último test hace 13 meses → se marca `review_required = True` - Técnica con test reciente → no se marca - Técnica nunca probada pero con status `not_evaluated` → no se marca --- ## FASE 5 — Gestión Operativa Avanzada **Duración estimada:** 2-3 semanas **Dependencias:** Fase 0 (scoring en BD) --- ### Tarea 5.1: Scoring Compuesto Maduro **Qué:** Reformar el scoring para que sea compuesto: % técnicas probadas × peso por criticidad × recencia × severidad. **Implementación:** Actualizar `scoring_service.py` para incluir factor de recencia (decay): ```python def _recency_factor(last_tested: datetime) -> float: """Factor de decaimiento: 1.0 si reciente, disminuye con el tiempo.""" if not last_tested: return 0.0 days_ago = (datetime.utcnow() - last_tested).days if days_ago <= 90: return 1.0 elif days_ago <= 180: return 0.8 elif days_ago <= 365: return 0.5 else: return 0.2 ``` Y persistir pesos en BD en vez de settings: ```python class ScoringConfig(Base): __tablename__ = "scoring_config" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) weight_tests = Column(Float, default=40.0) weight_detection = Column(Float, default=25.0) weight_d3fend = Column(Float, default=15.0) weight_recency = Column(Float, default=10.0) weight_severity = Column(Float, default=10.0) updated_by = Column(UUID(as_uuid=True), ForeignKey("users.id")) updated_at = Column(DateTime, default=datetime.utcnow) ``` **Verificación:** - Score de técnica probada hace 1 año es menor que una probada ayer (mismo resultado) - Cambiar pesos en BD se refleja inmediatamente en scores - Pesos persisten tras reinicio del servidor - Score compuesto total suma 100 siempre --- ### Tarea 5.2: Histórico y Evolución de Cobertura **Qué:** Expandir snapshots para comparar entre meses, equipos y tácticas. **Implementación:** Añadir campos al modelo CoverageSnapshot: ```python op.add_column('coverage_snapshots', Column('by_tactic', JSONB, default={})) op.add_column('coverage_snapshots', Column('by_status', JSONB, default={})) op.add_column('coverage_snapshots', Column('stale_count', Integer, default=0)) op.add_column('coverage_snapshots', Column('never_tested_count', Integer, default=0)) ``` Y endpoint para consumir tendencias: ```python @router.get("/evolution") def coverage_evolution(months: int = 12, db: Session = Depends(get_db), user=Depends(get_current_user)): cutoff = datetime.utcnow() - timedelta(days=months * 30) snapshots = db.query(CoverageSnapshot).filter( CoverageSnapshot.created_at >= cutoff ).order_by(CoverageSnapshot.created_at).all() return [ { "date": s.created_at.isoformat(), "org_score": s.org_score, "coverage_pct": s.coverage_percentage, "by_tactic": s.by_tactic, "stale_count": s.stale_count, } for s in snapshots ] ``` **Verificación:** - Snapshot semanal ahora incluye desglose por táctica - `/evolution?months=6` retorna snapshots de los últimos 6 meses - Gráfico de tendencias en frontend muestra línea temporal --- ## FASE 6 — Analytics para BI + Webhooks (Feature Adicional) **Duración estimada:** 2 semanas **Dependencias:** Fase 2 --- ### Tarea 6.1: Sistema de Webhooks **Qué:** Permitir que Aegis envíe notificaciones HTTP a sistemas externos cuando ocurren eventos. **Implementación:** 1. **Modelo — WebhookConfig:** ```python class WebhookConfig(Base): __tablename__ = "webhook_configs" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) name = Column(String(200), nullable=False) url = Column(Text, nullable=False) secret = Column(String(256)) # Para HMAC signature events = Column(JSONB, default=[]) # ["test.validated", "campaign.completed", ...] is_active = Column(Boolean, default=True) created_by = Column(UUID(as_uuid=True), ForeignKey("users.id")) last_triggered_at = Column(DateTime) failure_count = Column(Integer, default=0) ``` 2. **Servicio de dispatch:** ```python import requests import hashlib import hmac import json def dispatch_webhook(event_type: str, payload: dict): db = SessionLocal() try: configs = db.query(WebhookConfig).filter( WebhookConfig.is_active == True, WebhookConfig.events.contains([event_type]) ).all() for config in configs: try: body = json.dumps({ "event": event_type, "data": payload, "timestamp": datetime.utcnow().isoformat() }) headers = {"Content-Type": "application/json"} if config.secret: sig = hmac.new(config.secret.encode(), body.encode(), hashlib.sha256).hexdigest() headers["X-Aegis-Signature"] = sig resp = requests.post(config.url, data=body, headers=headers, timeout=10) config.last_triggered_at = datetime.utcnow() if resp.status_code >= 400: config.failure_count += 1 except Exception as e: config.failure_count += 1 logger.warning(f"Webhook {config.name} failed: {e}") db.commit() finally: db.close() ``` 3. Llamar `dispatch_webhook` en puntos clave: post-validación de test, completar campaña, sync MITRE, etc. **Verificación:** - Crear webhook para `test.validated` → validar un test → webhook recibe POST - Signature HMAC es verificable en el receptor - Webhook con URL inválida incrementa `failure_count` sin crashear - Desactivar webhook → no se dispara --- ## FASE 7 — Notificaciones Multi-Canal (Feature Adicional) **Duración estimada:** 1-2 semanas **Dependencias:** Fase 3 --- ### Tarea 7.1: Notificaciones por Email **Qué:** Añadir capacidad de enviar notificaciones por email además de in-app. **Implementación:** 1. **Config:** ```python SMTP_ENABLED: bool = False SMTP_HOST: str = "" SMTP_PORT: int = 587 SMTP_USERNAME: str = "" SMTP_PASSWORD: str = "" SMTP_FROM_EMAIL: str = "aegis@company.com" SMTP_USE_TLS: bool = True ``` 2. **app/services/email_service.py:** ```python import smtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from app.config import settings def send_email(to: str, subject: str, html_body: str): if not settings.SMTP_ENABLED: return msg = MIMEMultipart("alternative") msg["Subject"] = f"[Aegis] {subject}" msg["From"] = settings.SMTP_FROM_EMAIL msg["To"] = to msg.attach(MIMEText(html_body, "html")) with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server: if settings.SMTP_USE_TLS: server.starttls() if settings.SMTP_USERNAME: server.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD) server.send_message(msg) ``` 3. Integrar en `notification_service.py` — para eventos críticos (test validado, campaña completada, nueva técnica MITRE). **Verificación:** - Con SMTP configurado, validar test → email al lead correspondiente - Email incluye link directo al test en la plataforma - Con SMTP deshabilitado, no falla — solo skip --- ### Tarea 7.2: Preferencias de Notificación por Usuario **Migración:** ```python op.add_column('users', Column('notification_preferences', JSONB, default={ "email_on_test_validated": True, "email_on_campaign_completed": True, "email_on_new_mitre_techniques": False, "in_app_all": True, })) op.add_column('users', Column('jira_account_id', String(100))) # para Tempo ``` --- ## FASE 8 — Detection Lifecycle Management (DLM) **Duración estimada:** 3-4 semanas **Dependencias:** Fase 0 (Redis, índices, excepciones de dominio) > **Por qué aquí:** Todo el resto de las fases avanzadas (9-14) depende de que las detecciones tengan ciclo de vida. Sin decay, confidence score y revalidación, el resto opera sobre datos estáticos que pierden valor con el tiempo. > > **Nota sobre paralelismo:** Esta fase puede ejecutarse en paralelo con Fases 1-7, ya que su única dependencia real es Fase 0. Sin embargo, se recomienda tener al menos Fases 1-5 funcionando para que haya datos reales sobre los que el DLM opere. --- ### Tarea 8.1: Modelo de Datos — Infraestructura de Detección **Qué:** Crear las tablas que soportan el ciclo de vida de detecciones: metadatos de versión, asociaciones SIEM/EDR, y estado de salud. **Implementación:** **app/models/detection_lifecycle.py** (nuevo): ```python import uuid import enum from datetime import datetime from sqlalchemy import ( Column, String, Integer, Float, Boolean, DateTime, ForeignKey, Text, Enum as SQLEnum ) from sqlalchemy.dialects.postgresql import UUID, JSONB from sqlalchemy.orm import relationship from app.database import Base class DetectionConfidence(str, enum.Enum): fresh = "fresh" # Validado recientemente, todo OK aging = "aging" # Acercándose a caducidad stale = "stale" # Caducado, necesita revalidación broken = "broken" # Se detectó cambio que invalida unknown = "unknown" # Sin datos suficientes class DetectionHealthStatus(str, enum.Enum): healthy = "healthy" # Regla activa, disparando normalmente silent = "silent" # Regla no ha disparado en período esperado noisy = "noisy" # Regla disparando excesivamente (false positives) orphan = "orphan" # Regla sin owner asignado deprecated = "deprecated" # Regla marcada como obsoleta untested = "untested" # Regla nunca validada class InvalidationReason(str, enum.Enum): time_decay = "time_decay" mitre_update = "mitre_update" log_source_change = "log_source_change" siem_update = "siem_update" edr_update = "edr_update" infrastructure_change = "infrastructure_change" parser_change = "parser_change" manual = "manual" rule_modified = "rule_modified" class DetectionAsset(Base): """Representa un activo de detección concreto: una regla SIEM, query SPL, regla YARA, regla EDR, etc.""" __tablename__ = "detection_assets" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) name = Column(String(500), nullable=False) description = Column(Text) # Tipo y plataforma asset_type = Column(String(50), nullable=False) platform = Column(String(100)) # Contenido de la regla rule_content = Column(Text) rule_language = Column(String(50)) rule_repository_url = Column(Text) rule_file_path = Column(String(500)) # Versionado rule_version = Column(String(50)) rule_hash = Column(String(64)) last_rule_change_at = Column(DateTime) # Log source tracking log_source_name = Column(String(200)) log_source_version = Column(String(50)) log_source_config = Column(JSONB, default={}) # Infraestructura asociada infrastructure_hash = Column(String(64)) infrastructure_details = Column(JSONB, default={}) # Estado de salud health_status = Column( SQLEnum(DetectionHealthStatus), default=DetectionHealthStatus.untested ) last_alert_at = Column(DateTime) alert_count_30d = Column(Integer, default=0) false_positive_rate = Column(Float) expected_alert_frequency = Column(String(50)) # Ownership (se completa en Fase 9) owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id")) backup_owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id")) team = Column(String(100)) # Metadata is_active = Column(Boolean, default=True) tags = Column(JSONB, default=[]) metadata = Column(JSONB, default={}) created_by = Column(UUID(as_uuid=True), ForeignKey("users.id")) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) # Relationships technique_mappings = relationship( "DetectionTechniqueMapping", back_populates="detection_asset" ) validations = relationship( "DetectionValidation", back_populates="detection_asset" ) class DetectionTechniqueMapping(Base): """Mapeo N:M entre activos de detección y técnicas MITRE.""" __tablename__ = "detection_technique_mappings" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) detection_asset_id = Column( UUID(as_uuid=True), ForeignKey("detection_assets.id", ondelete="CASCADE"), nullable=False ) technique_id = Column( UUID(as_uuid=True), ForeignKey("techniques.id", ondelete="CASCADE"), nullable=False ) coverage_type = Column(String(50), default="detect") confidence_level = Column(String(20), default="medium") notes = Column(Text) created_at = Column(DateTime, default=datetime.utcnow) detection_asset = relationship( "DetectionAsset", back_populates="technique_mappings" ) class DetectionValidation(Base): """Registro inmutable de cada validación de una detección. Es el 'sello de calidad' con fecha de caducidad.""" __tablename__ = "detection_validations" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) detection_asset_id = Column( UUID(as_uuid=True), ForeignKey("detection_assets.id", ondelete="CASCADE"), nullable=False ) technique_id = Column(UUID(as_uuid=True), ForeignKey("techniques.id"), nullable=True) test_id = Column(UUID(as_uuid=True), ForeignKey("tests.id"), nullable=True) # Resultado validated_at = Column(DateTime, default=datetime.utcnow) expires_at = Column(DateTime, nullable=False) is_valid = Column(Boolean, default=True) validation_result = Column(String(50)) validation_method = Column(String(100)) # Snapshot del estado en el momento de validación rule_hash_at_validation = Column(String(64)) log_source_version_at_validation = Column(String(50)) infrastructure_hash_at_validation = Column(String(64)) environment_snapshot = Column(JSONB, default={}) # Invalidación invalidated_at = Column(DateTime) invalidation_reason = Column(SQLEnum(InvalidationReason)) invalidation_details = Column(Text) invalidated_by = Column(UUID(as_uuid=True), ForeignKey("users.id")) # Quién validó validated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) # Integridad integrity_hash = Column(String(64)) notes = Column(Text) evidence_ids = Column(JSONB, default=[]) detection_asset = relationship("DetectionAsset", back_populates="validations") class TechniqueConfidenceScore(Base): """Score calculado de confianza por técnica. Se recalcula periódicamente por el decay engine.""" __tablename__ = "technique_confidence_scores" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) technique_id = Column( UUID(as_uuid=True), ForeignKey("techniques.id", ondelete="CASCADE"), nullable=False, unique=True ) confidence_level = Column( SQLEnum(DetectionConfidence), default=DetectionConfidence.unknown ) confidence_score = Column(Float, default=0.0) detection_count = Column(Integer, default=0) valid_detection_count = Column(Integer, default=0) last_validated_at = Column(DateTime) next_validation_due = Column(DateTime) last_recalculated_at = Column(DateTime, default=datetime.utcnow) recency_factor = Column(Float, default=0.0) coverage_factor = Column(Float, default=0.0) health_factor = Column(Float, default=0.0) diversity_factor = Column(Float, default=0.0) score_breakdown = Column(JSONB, default={}) risk_factors = Column(JSONB, default=[]) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) class InfrastructureChangeLog(Base): """Registro de cambios en infraestructura que pueden invalidar detecciones.""" __tablename__ = "infrastructure_change_logs" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) change_type = Column(String(100), nullable=False) description = Column(Text, nullable=False) affected_platforms = Column(JSONB, default=[]) affected_log_sources = Column(JSONB, default=[]) change_date = Column(DateTime, default=datetime.utcnow) reported_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) auto_invalidate = Column(Boolean, default=True) invalidated_count = Column(Integer, default=0) metadata = Column(JSONB, default={}) created_at = Column(DateTime, default=datetime.utcnow) ``` **Migración Alembic:** ```bash alembic revision --autogenerate -m "add_detection_lifecycle_tables" alembic upgrade head ``` **Índices adicionales** (agregar manualmente en la migración): ```python def upgrade(): # ... tablas auto-generadas ... op.create_index('ix_detection_assets_platform', 'detection_assets', ['platform']) op.create_index('ix_detection_assets_health_status', 'detection_assets', ['health_status']) op.create_index('ix_detection_assets_owner_id', 'detection_assets', ['owner_id']) op.create_index('ix_detection_technique_mappings_technique_id', 'detection_technique_mappings', ['technique_id']) op.create_index('ix_detection_technique_mappings_asset_id', 'detection_technique_mappings', ['detection_asset_id']) op.create_index('ix_detection_validations_asset_id_valid', 'detection_validations', ['detection_asset_id', 'is_valid']) op.create_index('ix_detection_validations_expires_at', 'detection_validations', ['expires_at']) op.create_index('ix_technique_confidence_scores_technique_id', 'technique_confidence_scores', ['technique_id']) op.create_index('ix_technique_confidence_scores_confidence_level', 'technique_confidence_scores', ['confidence_level']) op.create_index('ix_infrastructure_change_logs_change_date', 'infrastructure_change_logs', ['change_date']) ``` **app/schemas/detection_lifecycle_schema.py** (nuevo): ```python from pydantic import BaseModel, Field from typing import Optional from uuid import UUID from datetime import datetime from app.models.detection_lifecycle import ( DetectionConfidence, DetectionHealthStatus, InvalidationReason ) class DetectionAssetCreate(BaseModel): name: str = Field(..., min_length=3, max_length=500) description: Optional[str] = None asset_type: str = Field( ..., pattern=r'^(siem_rule|edr_rule|sigma_rule|yara_rule|spl_query|kql_query|custom_script)$' ) platform: Optional[str] = None rule_content: Optional[str] = None rule_language: Optional[str] = None rule_repository_url: Optional[str] = None rule_file_path: Optional[str] = None rule_version: Optional[str] = None log_source_name: Optional[str] = None log_source_version: Optional[str] = None log_source_config: Optional[dict] = {} infrastructure_details: Optional[dict] = {} expected_alert_frequency: Optional[str] = None tags: Optional[list[str]] = [] technique_ids: Optional[list[UUID]] = [] class DetectionAssetUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None rule_content: Optional[str] = None rule_version: Optional[str] = None log_source_version: Optional[str] = None infrastructure_details: Optional[dict] = None expected_alert_frequency: Optional[str] = None health_status: Optional[DetectionHealthStatus] = None last_alert_at: Optional[datetime] = None alert_count_30d: Optional[int] = None false_positive_rate: Optional[float] = None owner_id: Optional[UUID] = None backup_owner_id: Optional[UUID] = None team: Optional[str] = None tags: Optional[list[str]] = None is_active: Optional[bool] = None class DetectionAssetOut(BaseModel): id: UUID name: str description: Optional[str] asset_type: str platform: Optional[str] rule_language: Optional[str] rule_version: Optional[str] rule_hash: Optional[str] health_status: DetectionHealthStatus last_alert_at: Optional[datetime] alert_count_30d: int false_positive_rate: Optional[float] expected_alert_frequency: Optional[str] owner_id: Optional[UUID] team: Optional[str] is_active: bool tags: list created_at: datetime updated_at: datetime class Config: from_attributes = True class DetectionValidationCreate(BaseModel): detection_asset_id: UUID technique_id: Optional[UUID] = None test_id: Optional[UUID] = None validation_result: str = Field( ..., pattern=r'^(detected|not_detected|partial|error)$' ) validation_method: str notes: Optional[str] = None evidence_ids: Optional[list[UUID]] = [] validity_days: int = Field(default=180, ge=30, le=730) class DetectionValidationOut(BaseModel): id: UUID detection_asset_id: UUID technique_id: Optional[UUID] validated_at: datetime expires_at: datetime is_valid: bool validation_result: str validation_method: str invalidated_at: Optional[datetime] invalidation_reason: Optional[InvalidationReason] validated_by: UUID notes: Optional[str] class Config: from_attributes = True class TechniqueConfidenceOut(BaseModel): technique_id: UUID confidence_level: DetectionConfidence confidence_score: float detection_count: int valid_detection_count: int last_validated_at: Optional[datetime] next_validation_due: Optional[datetime] recency_factor: float coverage_factor: float health_factor: float diversity_factor: float risk_factors: list class Config: from_attributes = True class InfrastructureChangeCreate(BaseModel): change_type: str description: str = Field(..., min_length=10) affected_platforms: list[str] = [] affected_log_sources: list[str] = [] change_date: Optional[datetime] = None auto_invalidate: bool = True class InfrastructureChangeOut(BaseModel): id: UUID change_type: str description: str affected_platforms: list affected_log_sources: list change_date: datetime auto_invalidate: bool invalidated_count: int reported_by: UUID created_at: datetime class Config: from_attributes = True ``` **Verificación:** - `alembic upgrade head` ejecuta sin error - `\dt` en psql muestra las 5 nuevas tablas - `\di` muestra todos los índices creados - Schemas validan correctamente: `DetectionAssetCreate(name="x", asset_type="invalid")` → error - Los JSONB defaults funcionan: crear un DetectionAsset sin tags → `tags = []` --- ### Tarea 8.2: Servicio de Detection Assets — CRUD + Versionado **Qué:** Servicio que encapsula la gestión de activos de detección con auto-hash de contenido y detección de cambios. **Implementación:** **app/services/detection_asset_service.py** (nuevo): ```python import hashlib import logging from datetime import datetime from typing import Optional from uuid import UUID from sqlalchemy.orm import Session, joinedload from sqlalchemy import func from app.models.detection_lifecycle import ( DetectionAsset, DetectionTechniqueMapping, DetectionValidation, DetectionHealthStatus ) from app.models.technique import Technique from app.domain.exceptions import ( EntityNotFoundError, DuplicateEntityError, InvalidOperationError ) from app.services import audit_service logger = logging.getLogger(__name__) def _compute_rule_hash(content: str) -> str: """SHA256 del contenido de la regla, normalizado.""" normalized = content.strip().replace('\r\n', '\n') return hashlib.sha256(normalized.encode()).hexdigest() def create_detection_asset( db: Session, data: dict, user_id: UUID ) -> DetectionAsset: """Crea un nuevo activo de detección con hash automático.""" technique_ids = data.pop("technique_ids", []) asset = DetectionAsset(**data, created_by=user_id) if asset.rule_content: asset.rule_hash = _compute_rule_hash(asset.rule_content) asset.last_rule_change_at = datetime.utcnow() if asset.infrastructure_details: infra_str = str(sorted(asset.infrastructure_details.items())) asset.infrastructure_hash = hashlib.sha256(infra_str.encode()).hexdigest() db.add(asset) db.flush() for tech_id in technique_ids: technique = db.query(Technique).filter(Technique.id == tech_id).first() if technique: mapping = DetectionTechniqueMapping( detection_asset_id=asset.id, technique_id=tech_id, ) db.add(mapping) db.commit() db.refresh(asset) audit_service.log_action( db, user_id, "DETECTION_ASSET_CREATED", "detection_asset", str(asset.id), details={ "name": asset.name, "type": asset.asset_type, "platform": asset.platform, "technique_count": len(technique_ids), } ) return asset def update_detection_asset( db: Session, asset_id: UUID, data: dict, user_id: UUID ) -> DetectionAsset: """Actualiza un activo, detectando cambios en contenido de regla.""" asset = db.query(DetectionAsset).filter(DetectionAsset.id == asset_id).first() if not asset: raise EntityNotFoundError("DetectionAsset", str(asset_id)) changes = {} rule_changed = False for key, value in data.items(): if value is not None and hasattr(asset, key): old_value = getattr(asset, key) if old_value != value: changes[key] = {"old": str(old_value), "new": str(value)} setattr(asset, key, value) if "rule_content" in data and data["rule_content"]: new_hash = _compute_rule_hash(data["rule_content"]) if new_hash != asset.rule_hash: rule_changed = True asset.rule_hash = new_hash asset.last_rule_change_at = datetime.utcnow() changes["rule_hash"] = {"old": asset.rule_hash, "new": new_hash} if "infrastructure_details" in data and data["infrastructure_details"]: infra_str = str(sorted(data["infrastructure_details"].items())) new_hash = hashlib.sha256(infra_str.encode()).hexdigest() if new_hash != asset.infrastructure_hash: asset.infrastructure_hash = new_hash changes["infrastructure_hash_changed"] = True db.commit() db.refresh(asset) if changes: audit_service.log_action( db, user_id, "DETECTION_ASSET_UPDATED", "detection_asset", str(asset.id), details={"changes": changes, "rule_changed": rule_changed} ) if rule_changed: _invalidate_validations_for_asset(db, asset.id, user_id, "rule_modified") return asset def _invalidate_validations_for_asset( db: Session, asset_id: UUID, user_id: UUID, reason: str ): """Invalida todas las validaciones vigentes de un asset.""" validations = db.query(DetectionValidation).filter( DetectionValidation.detection_asset_id == asset_id, DetectionValidation.is_valid == True, ).all() count = 0 for v in validations: v.is_valid = False v.invalidated_at = datetime.utcnow() v.invalidation_reason = reason v.invalidated_by = user_id count += 1 if count > 0: db.commit() logger.info(f"Invalidated {count} validations for asset {asset_id} due to {reason}") return count def get_asset_with_details(db: Session, asset_id: UUID) -> DetectionAsset: """Obtiene un asset con sus mapeos y validaciones.""" asset = ( db.query(DetectionAsset) .options( joinedload(DetectionAsset.technique_mappings), joinedload(DetectionAsset.validations), ) .filter(DetectionAsset.id == asset_id) .first() ) if not asset: raise EntityNotFoundError("DetectionAsset", str(asset_id)) return asset def list_assets( db: Session, platform: Optional[str] = None, asset_type: Optional[str] = None, health_status: Optional[str] = None, technique_id: Optional[UUID] = None, is_active: Optional[bool] = True, ) -> list[DetectionAsset]: """Lista assets con filtros opcionales.""" query = db.query(DetectionAsset) if platform: query = query.filter(DetectionAsset.platform == platform) if asset_type: query = query.filter(DetectionAsset.asset_type == asset_type) if health_status: query = query.filter(DetectionAsset.health_status == health_status) if is_active is not None: query = query.filter(DetectionAsset.is_active == is_active) if technique_id: query = query.join(DetectionTechniqueMapping).filter( DetectionTechniqueMapping.technique_id == technique_id ) return query.order_by(DetectionAsset.name).all() def get_technique_detection_summary(db: Session, technique_id: UUID) -> dict: """Resumen de detecciones para una técnica específica.""" mappings = ( db.query(DetectionTechniqueMapping) .options(joinedload(DetectionTechniqueMapping.detection_asset)) .filter(DetectionTechniqueMapping.technique_id == technique_id) .all() ) assets = [m.detection_asset for m in mappings if m.detection_asset] active_assets = [a for a in assets if a.is_active] valid_count = 0 for asset in active_assets: has_valid = db.query(DetectionValidation).filter( DetectionValidation.detection_asset_id == asset.id, DetectionValidation.is_valid == True, DetectionValidation.expires_at > datetime.utcnow(), ).first() if has_valid: valid_count += 1 health_distribution = {} for asset in active_assets: status = asset.health_status.value if asset.health_status else "unknown" health_distribution[status] = health_distribution.get(status, 0) + 1 platforms = list(set(a.platform for a in active_assets if a.platform)) return { "technique_id": str(technique_id), "total_assets": len(active_assets), "validated_assets": valid_count, "health_distribution": health_distribution, "platforms": platforms, "coverage_types": list(set(m.coverage_type for m in mappings if m.coverage_type)), } ``` **Verificación:** - Crear un DetectionAsset con `rule_content` → `rule_hash` se genera automáticamente - Actualizar `rule_content` con contenido diferente → hash cambia + `last_rule_change_at` se actualiza + validaciones vigentes se invalidan - Actualizar con mismo contenido → hash no cambia, validaciones intactas - `list_assets(platform="Splunk")` filtra correctamente - `get_technique_detection_summary` retorna conteos correctos - Audit log registra creación y actualización con detalles de cambios --- ### Tarea 8.3: Decay Engine — Motor de Obsolescencia **Qué:** El cerebro del sistema de lifecycle. Evalúa periódicamente todas las detecciones y degrada su estado según políticas configurables. > **Nota:** Este motor reemplaza funcionalmente la detección simple de Fase 4 (Tarea 4.2). La Tarea 4.2 puede seguir activa como fallback simple, pero el Decay Engine es el sistema definitivo. **Implementación:** **app/models/decay_policy.py** (nuevo): ```python import uuid from datetime import datetime from sqlalchemy import Column, String, Integer, Float, Boolean, DateTime from sqlalchemy.dialects.postgresql import UUID, JSONB from app.database import Base class DecayPolicy(Base): """Política de caducidad configurable por plataforma/tipo.""" __tablename__ = "decay_policies" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) name = Column(String(200), nullable=False) description = Column(String(500)) # Alcance applies_to_platform = Column(String(100)) # null = todas applies_to_asset_type = Column(String(50)) # null = todos applies_to_tactic = Column(String(100)) # null = todas # Umbrales de tiempo (días) fresh_days = Column(Integer, default=90) # 0-90 = Fresh aging_days = Column(Integer, default=180) # 91-180 = Aging stale_days = Column(Integer, default=365) # 181-365 = Stale # >365 = Broken (implícito) # Validación por defecto default_validity_days = Column(Integer, default=180) # Health: umbrales silent_threshold_days = Column(Integer, default=30) noisy_threshold_daily = Column(Integer, default=100) # Pesos de factores recency_weight = Column(Float, default=0.3) coverage_weight = Column(Float, default=0.3) health_weight = Column(Float, default=0.25) diversity_weight = Column(Float, default=0.15) is_default = Column(Boolean, default=False) is_active = Column(Boolean, default=True) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) ``` **app/services/decay_engine_service.py** (nuevo): ```python import logging from datetime import datetime, timedelta from typing import Optional from uuid import UUID from sqlalchemy.orm import Session from sqlalchemy import func, and_ from app.models.detection_lifecycle import ( DetectionAsset, DetectionValidation, DetectionTechniqueMapping, TechniqueConfidenceScore, DetectionConfidence, DetectionHealthStatus, InfrastructureChangeLog ) from app.models.decay_policy import DecayPolicy from app.models.technique import Technique logger = logging.getLogger(__name__) def get_applicable_policy( db: Session, platform: Optional[str] = None, asset_type: Optional[str] = None, tactic: Optional[str] = None, ) -> DecayPolicy: """Obtiene la política más específica aplicable.""" query = db.query(DecayPolicy).filter(DecayPolicy.is_active == True) if platform: specific = query.filter(DecayPolicy.applies_to_platform == platform).first() if specific: return specific if asset_type: specific = query.filter(DecayPolicy.applies_to_asset_type == asset_type).first() if specific: return specific default_policy = query.filter(DecayPolicy.is_default == True).first() if default_policy: return default_policy return DecayPolicy() def calculate_confidence_for_technique( db: Session, technique_id: UUID ) -> TechniqueConfidenceScore: """Calcula el Confidence Score completo para una técnica.""" technique = db.query(Technique).filter(Technique.id == technique_id).first() if not technique: return None policy = get_applicable_policy(db, tactic=technique.tactic) mappings = ( db.query(DetectionTechniqueMapping) .filter(DetectionTechniqueMapping.technique_id == technique_id) .all() ) asset_ids = [m.detection_asset_id for m in mappings] if not asset_ids: return _create_or_update_score( db, technique_id, confidence_level=DetectionConfidence.unknown, confidence_score=0.0, factors={"recency": 0.0, "coverage": 0.0, "health": 0.0, "diversity": 0.0}, risk_factors=["no_detection_assets"], detection_count=0, valid_count=0, ) assets = ( db.query(DetectionAsset) .filter(DetectionAsset.id.in_(asset_ids), DetectionAsset.is_active == True) .all() ) now = datetime.utcnow() # 1. RECENCY FACTOR valid_validations = ( db.query(DetectionValidation) .filter( DetectionValidation.detection_asset_id.in_(asset_ids), DetectionValidation.is_valid == True, DetectionValidation.expires_at > now, ) .all() ) recency_factor = 0.0 last_validated = None if valid_validations: most_recent = max(v.validated_at for v in valid_validations) last_validated = most_recent days_since = (now - most_recent).days if days_since <= policy.fresh_days: recency_factor = 1.0 elif days_since <= policy.aging_days: range_days = policy.aging_days - policy.fresh_days elapsed = days_since - policy.fresh_days recency_factor = 1.0 - (elapsed / range_days) * 0.4 elif days_since <= policy.stale_days: range_days = policy.stale_days - policy.aging_days elapsed = days_since - policy.aging_days recency_factor = 0.6 - (elapsed / range_days) * 0.4 else: recency_factor = max(0.1, 0.2 - ((days_since - policy.stale_days) / 365) * 0.1) # 2. COVERAGE FACTOR active_count = len(assets) valid_count = len(set(v.detection_asset_id for v in valid_validations)) if active_count == 0: coverage_factor = 0.0 elif valid_count >= 3: coverage_factor = 1.0 elif valid_count >= 2: coverage_factor = 0.8 elif valid_count >= 1: coverage_factor = 0.5 else: coverage_factor = 0.1 # 3. HEALTH FACTOR health_scores = { DetectionHealthStatus.healthy: 1.0, DetectionHealthStatus.silent: 0.4, DetectionHealthStatus.noisy: 0.6, DetectionHealthStatus.orphan: 0.3, DetectionHealthStatus.deprecated: 0.0, DetectionHealthStatus.untested: 0.2, } if assets: health_values = [health_scores.get(a.health_status, 0.2) for a in assets] health_factor = sum(health_values) / len(health_values) else: health_factor = 0.0 # 4. DIVERSITY FACTOR platforms = set(a.platform for a in assets if a.platform) asset_types = set(a.asset_type for a in assets) diversity_factor = min(1.0, (len(platforms) * 0.3 + len(asset_types) * 0.2)) # SCORE COMPUESTO confidence_score = ( recency_factor * policy.recency_weight + coverage_factor * policy.coverage_weight + health_factor * policy.health_weight + diversity_factor * policy.diversity_weight ) * 100 # CONFIDENCE LEVEL if confidence_score >= 75: confidence_level = DetectionConfidence.fresh elif confidence_score >= 50: confidence_level = DetectionConfidence.aging elif confidence_score >= 25: confidence_level = DetectionConfidence.stale elif confidence_score > 0: confidence_level = DetectionConfidence.broken else: confidence_level = DetectionConfidence.unknown # RISK FACTORS risk_factors = [] if len(platforms) <= 1: risk_factors.append("single_platform") if valid_count == 0: risk_factors.append("no_valid_detections") if any(a.health_status == DetectionHealthStatus.silent for a in assets): risk_factors.append("silent_rules_present") if any(a.health_status == DetectionHealthStatus.orphan for a in assets): risk_factors.append("orphan_rules_present") if recency_factor < 0.5: risk_factors.append("stale_validation") if len(assets) < 2: risk_factors.append("low_detection_diversity") next_due = None if valid_validations: earliest_expiry = min(v.expires_at for v in valid_validations) next_due = earliest_expiry return _create_or_update_score( db, technique_id, confidence_level=confidence_level, confidence_score=round(confidence_score, 1), factors={ "recency": round(recency_factor, 3), "coverage": round(coverage_factor, 3), "health": round(health_factor, 3), "diversity": round(diversity_factor, 3), }, risk_factors=risk_factors, detection_count=active_count, valid_count=valid_count, last_validated=last_validated, next_due=next_due, ) def _create_or_update_score(db: Session, technique_id: UUID, **kwargs) -> TechniqueConfidenceScore: """Crea o actualiza el score de confianza.""" score = db.query(TechniqueConfidenceScore).filter( TechniqueConfidenceScore.technique_id == technique_id ).first() if not score: score = TechniqueConfidenceScore(technique_id=technique_id) db.add(score) score.confidence_level = kwargs["confidence_level"] score.confidence_score = kwargs["confidence_score"] score.detection_count = kwargs["detection_count"] score.valid_detection_count = kwargs["valid_count"] score.recency_factor = kwargs["factors"]["recency"] score.coverage_factor = kwargs["factors"]["coverage"] score.health_factor = kwargs["factors"]["health"] score.diversity_factor = kwargs["factors"]["diversity"] score.risk_factors = kwargs["risk_factors"] score.score_breakdown = kwargs["factors"] score.last_validated_at = kwargs.get("last_validated") score.next_validation_due = kwargs.get("next_due") score.last_recalculated_at = datetime.utcnow() db.commit() db.refresh(score) return score def run_decay_engine(db: Session) -> dict: """Ejecuta el motor de decay sobre todas las técnicas. Job diario.""" techniques = db.query(Technique).all() results = { "total_techniques": len(techniques), "fresh": 0, "aging": 0, "stale": 0, "broken": 0, "unknown": 0, "validations_expired": 0, } now = datetime.utcnow() # 1. Expirar validaciones vencidas expired = ( db.query(DetectionValidation) .filter(DetectionValidation.is_valid == True, DetectionValidation.expires_at <= now) .all() ) for v in expired: v.is_valid = False v.invalidated_at = now v.invalidation_reason = "time_decay" results["validations_expired"] = len(expired) if expired: db.commit() # 2. Recalcular confidence scores for technique in techniques: score = calculate_confidence_for_technique(db, technique.id) if score: level = score.confidence_level.value results[level] = results.get(level, 0) + 1 logger.info(f"Decay engine completed: {results}") return results def process_infrastructure_change(db: Session, change_id: UUID) -> int: """Procesa un cambio de infraestructura e invalida detecciones afectadas.""" change = db.query(InfrastructureChangeLog).filter( InfrastructureChangeLog.id == change_id ).first() if not change or not change.auto_invalidate: return 0 query = db.query(DetectionAsset).filter(DetectionAsset.is_active == True) if change.affected_platforms: query = query.filter(DetectionAsset.platform.in_(change.affected_platforms)) affected_assets = query.all() total_invalidated = 0 for asset in affected_assets: if change.affected_log_sources: asset_log_source = asset.log_source_name or "" if not any(ls in asset_log_source for ls in change.affected_log_sources): continue count = _invalidate_validations_for_asset( db, asset.id, change.reported_by, "infrastructure_change" ) total_invalidated += count change.invalidated_count = total_invalidated db.commit() logger.info(f"Infrastructure change {change_id}: invalidated {total_invalidated} validations") return total_invalidated def _invalidate_validations_for_asset( db: Session, asset_id: UUID, user_id: UUID, reason: str ) -> int: """Invalida validaciones vigentes de un asset.""" validations = db.query(DetectionValidation).filter( DetectionValidation.detection_asset_id == asset_id, DetectionValidation.is_valid == True, ).all() for v in validations: v.is_valid = False v.invalidated_at = datetime.utcnow() v.invalidation_reason = reason v.invalidated_by = user_id return len(validations) ``` **Registrar job en scheduler:** ```python scheduler.add_job( decay_engine_job, "cron", hour=2, minute=0, id="decay_engine", replace_existing=True, ) def decay_engine_job(): db = SessionLocal() try: from app.services.decay_engine_service import run_decay_engine results = run_decay_engine(db) logger.info(f"Decay engine job results: {results}") except Exception as e: logger.error(f"Decay engine job failed: {e}", exc_info=True) finally: db.close() ``` **Verificación:** - Crear asset + validación con `expires_at = ayer` → ejecutar `run_decay_engine()` → validación marcada `is_valid=False` con `invalidation_reason="time_decay"` - Técnica con validación vigente → `confidence_level = "fresh"` - Técnica con validación de hace 100 días → `confidence_level = "aging"` - Técnica sin validaciones → `confidence_level = "unknown"` - Registrar infrastructure change con `affected_platforms=["Splunk"]` → invalida validaciones de assets Splunk - Log muestra resumen completo del decay engine --- ### Tarea 8.4: Router de Detection Lifecycle **Qué:** Endpoints API para gestionar todo el ciclo de vida de detecciones. **Implementación:** **app/routers/detection_lifecycle.py** (nuevo): ```python from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session from uuid import UUID from typing import Optional from datetime import datetime, timedelta from app.database import get_db from app.dependencies.auth import get_current_user, require_role 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 from app.services import audit_service from app.models.detection_lifecycle import ( DetectionAsset, DetectionValidation, DetectionTechniqueMapping, TechniqueConfidenceScore, InfrastructureChangeLog, DetectionHealthStatus, ) from app.models.decay_policy import DecayPolicy from app.domain.exceptions import EntityNotFoundError import hashlib router = APIRouter(prefix="/detection-lifecycle", tags=["detection-lifecycle"]) # — 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), ): return detection_asset_service.create_detection_asset(db, body.model_dump(), user.id) @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}") 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 ) # — 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)) 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=body.evidence_ids or [], validated_by=user.id, expires_at=datetime.utcnow() + 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_role("admin", "blue_lead")), ): validation = db.query(DetectionValidation).filter( DetectionValidation.id == validation_id ).first() if not validation: raise EntityNotFoundError("DetectionValidation", str(validation_id)) validation.is_valid = False validation.invalidated_at = datetime.utcnow() validation.invalidation_reason = reason 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_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 datetime.utcnow(), 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 = datetime.utcnow() - 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_role("admin")), ): results = decay_engine_service.run_decay_engine(db) return {"message": "Decay engine completed", "results": results} # — Dashboard Summary — @router.get("/dashboard") def lifecycle_dashboard( db: Session = Depends(get_db), user=Depends(get_current_user), ): """Resumen ejecutivo del estado de detecciones.""" from sqlalchemy import func 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 <= (datetime.utcnow() + timedelta(days=7)), ) .scalar() ) total_assets = db.query(func.count(DetectionAsset.id)).filter( DetectionAsset.is_active == True ).scalar() total_valid = db.query(func.count(DetectionValidation.id)).filter( DetectionValidation.is_valid == True ).scalar() recent_changes = db.query(func.count(InfrastructureChangeLog.id)).filter( InfrastructureChangeLog.change_date >= (datetime.utcnow() - timedelta(days=30)) ).scalar() 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, } ``` Registrar en main.py: ```python from app.routers.detection_lifecycle import router as detection_lifecycle_router app.include_router(detection_lifecycle_router, prefix="/api/v1") ``` **Verificación:** - `POST /api/v1/detection-lifecycle/assets` con datos válidos → 201, asset creado con hash - `POST /api/v1/detection-lifecycle/validations` → validación con `expires_at` correcto - `GET /api/v1/detection-lifecycle/confidence/{technique_id}?recalculate=true` → score calculado - `POST /api/v1/detection-lifecycle/infrastructure-changes` con `auto_invalidate=true` → validaciones afectadas invalidadas - `GET /api/v1/detection-lifecycle/dashboard` → resumen completo con distribuciones - `POST /api/v1/detection-lifecycle/decay-engine/run` → resultados del decay completo --- ### Tarea 8.5: Seed de Decay Policy por Defecto **Qué:** Crear una política por defecto durante el seed de la aplicación. **Implementación:** En `app/seed.py` o el script de seed existente: ```python from app.models.decay_policy import DecayPolicy def seed_decay_policies(db: Session): """Crear política de decay por defecto si no existe.""" existing = db.query(DecayPolicy).filter(DecayPolicy.is_default == True).first() if existing: return default_policy = DecayPolicy( name="Default Decay Policy", description="Standard detection decay: Fresh up to 90 days, Aging 91-180 days, Stale 181-365 days, Broken after 365 days.", fresh_days=90, aging_days=180, stale_days=365, default_validity_days=180, silent_threshold_days=30, noisy_threshold_daily=100, recency_weight=0.30, coverage_weight=0.30, health_weight=0.25, diversity_weight=0.15, is_default=True, is_active=True, ) db.add(default_policy) critical_policy = DecayPolicy( name="Critical Techniques Policy", description="Stricter policy for high-impact tactics: Fresh 60 days, Aging 90 days, Stale 180 days.", applies_to_tactic="initial-access", fresh_days=60, aging_days=90, stale_days=180, default_validity_days=90, silent_threshold_days=14, noisy_threshold_daily=50, recency_weight=0.35, coverage_weight=0.30, health_weight=0.25, diversity_weight=0.10, is_default=False, is_active=True, ) db.add(critical_policy) db.commit() ``` **Verificación:** - Ejecutar seed → dos políticas creadas en `decay_policies` - Ejecutar seed de nuevo → no duplica - La política de initial-access tiene umbrales más estrictos --- ## FASE 9 — Ownership & Operativa Diaria **Duración estimada:** 2-3 semanas **Dependencias:** Fase 8 --- ### Tarea 9.1: Sistema de Ownership **Qué:** Cada técnica y regla de detección tiene un owner responsable, backup owner, y equipo. **Implementación:** 1. **Migración Alembic** — Añadir columnas a `techniques`: ```python def upgrade(): op.add_column('techniques', Column('owner_id', UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)) op.add_column('techniques', Column('backup_owner_id', UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)) op.add_column('techniques', Column('team', String(100), nullable=True)) op.add_column('techniques', Column('ownership_assigned_at', DateTime, nullable=True)) op.create_index('ix_techniques_owner_id', 'techniques', ['owner_id']) ``` 2. **app/services/ownership_service.py** (nuevo): ```python import logging from datetime import datetime from uuid import UUID from sqlalchemy.orm import Session from sqlalchemy import func from app.models.technique import Technique from app.models.user import User logger = logging.getLogger(__name__) def assign_owner(db: Session, technique_id: UUID, owner_id: UUID, backup_owner_id: UUID = None, team: str = None): technique = db.query(Technique).filter(Technique.id == technique_id).first() if not technique: from app.domain.exceptions import EntityNotFoundError raise EntityNotFoundError("Technique", str(technique_id)) technique.owner_id = owner_id technique.backup_owner_id = backup_owner_id technique.team = team technique.ownership_assigned_at = datetime.utcnow() db.commit() return technique def bulk_assign_by_tactic(db: Session, tactic: str, owner_id: UUID, team: str = None): """Asignar owner a todas las técnicas de una táctica.""" techniques = db.query(Technique).filter(Technique.tactic == tactic).all() count = 0 for t in techniques: if not t.owner_id: t.owner_id = owner_id t.team = team t.ownership_assigned_at = datetime.utcnow() count += 1 db.commit() return count def get_orphan_techniques(db: Session) -> list: """Técnicas sin owner asignado.""" return db.query(Technique).filter(Technique.owner_id.is_(None)).order_by(Technique.mitre_id).all() def get_workload_by_user(db: Session) -> list[dict]: """Carga de trabajo por usuario.""" results = ( db.query(User.username, User.role, func.count(Technique.id).label("technique_count")) .outerjoin(Technique, Technique.owner_id == User.id) .group_by(User.id) .all() ) return [{"username": r[0], "role": r[1], "technique_count": r[2]} for r in results] ``` 3. **app/routers/ownership.py** (nuevo) — Endpoints para asignar owners, ver huérfanos, ver carga. **Verificación:** - Asignar owner a técnica → `owner_id` y `ownership_assigned_at` se actualizan - `get_orphan_techniques()` retorna técnicas sin owner - Bulk assign por táctica asigna solo las que no tienen owner - Workload muestra conteo correcto por usuario --- ### Tarea 9.2: Cola de Revalidación **Qué:** Sistema que genera automáticamente una cola priorizada de técnicas/detecciones que necesitan revalidación. **Implementación:** **app/models/revalidation_queue.py** (nuevo): ```python import uuid import enum from datetime import datetime from sqlalchemy import ( Column, String, Integer, DateTime, ForeignKey, Text, Enum as SQLEnum, Boolean ) from sqlalchemy.dialects.postgresql import UUID, JSONB from app.database import Base class RevalidationPriority(str, enum.Enum): critical = "critical" high = "high" medium = "medium" low = "low" class RevalidationStatus(str, enum.Enum): pending = "pending" assigned = "assigned" in_progress = "in_progress" completed = "completed" skipped = "skipped" class RevalidationItem(Base): __tablename__ = "revalidation_queue" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) technique_id = Column(UUID(as_uuid=True), ForeignKey("techniques.id"), nullable=False) detection_asset_id = Column(UUID(as_uuid=True), ForeignKey("detection_assets.id"), nullable=True) reason = Column(String(200), nullable=False) priority = Column(SQLEnum(RevalidationPriority), default=RevalidationPriority.medium) status = Column(SQLEnum(RevalidationStatus), default=RevalidationStatus.pending) assigned_to = Column(UUID(as_uuid=True), ForeignKey("users.id")) assigned_at = Column(DateTime) due_date = Column(DateTime) completed_at = Column(DateTime) completed_by = Column(UUID(as_uuid=True), ForeignKey("users.id")) result_validation_id = Column(UUID(as_uuid=True), ForeignKey("detection_validations.id")) notes = Column(Text) metadata = Column(JSONB, default={}) created_at = Column(DateTime, default=datetime.utcnow) ``` **app/services/revalidation_service.py** — Genera cola automáticamente basándose en: validaciones expiradas, cambios de infra, coverage stale, updates MITRE. Prioriza por criticidad de la táctica y risk score. **Verificación:** - Ejecutar generación de cola → items creados con prioridad correcta - Asignar item a usuario → status cambia a "assigned" - Completar item con nueva validación → status "completed" + `result_validation_id` poblado - Cola respeta priorización (critical > high > medium > low) --- ### Tarea 9.3: Dashboard del Analista — API **Qué:** Endpoint que retorna la vista personalizada del día para cada analista. **Implementación:** **app/routers/analyst_dashboard.py** (nuevo): ```python @router.get("/my-dashboard") def get_my_dashboard( db: Session = Depends(get_db), user=Depends(get_current_user), ): """Vista personalizada del día para el analista logueado.""" now = datetime.utcnow() # 1. Mis revalidaciones pendientes my_revalidations = ( db.query(RevalidationItem) .filter( RevalidationItem.assigned_to == user.id, RevalidationItem.status.in_(["pending", "assigned", "in_progress"]), ) .order_by(RevalidationItem.priority.asc(), RevalidationItem.due_date.asc()) .limit(10) .all() ) # 2. Mis técnicas con validaciones que caducan esta semana my_expiring = ( db.query(DetectionValidation) .join(DetectionTechniqueMapping, DetectionValidation.detection_asset_id == DetectionTechniqueMapping.detection_asset_id) .join(Technique, DetectionTechniqueMapping.technique_id == Technique.id) .filter( Technique.owner_id == user.id, DetectionValidation.is_valid == True, DetectionValidation.expires_at <= (now + timedelta(days=7)), ) .all() ) # 3. Mis tests pendientes de workflow my_tests = ( db.query(Test) .filter( Test.created_by == user.id, Test.state.in_(["draft", "red_executing", "blue_evaluating"]) ) .order_by(Test.updated_at.desc()) .limit(10) .all() ) # 4. Notificaciones no leídas unread_notifications = ( db.query(func.count(Notification.id)) .filter(Notification.user_id == user.id, Notification.is_read == False) .scalar() ) # 5. Cambios de infra recientes recent_changes = ( db.query(InfrastructureChangeLog) .filter(InfrastructureChangeLog.change_date >= (now - timedelta(days=7))) .order_by(InfrastructureChangeLog.change_date.desc()) .limit(5) .all() ) return { "user": {"username": user.username, "role": user.role}, "date": now.isoformat(), "revalidations_pending": len(my_revalidations), "revalidations": [_serialize_revalidation(r) for r in my_revalidations], "expiring_validations": len(my_expiring), "active_tests": len(my_tests), "unread_notifications": unread_notifications, "recent_infrastructure_changes": len(recent_changes), } ``` **Verificación:** - `GET /api/v1/analyst/my-dashboard` retorna datos personalizados del usuario logueado - Revalidaciones aparecen ordenadas por prioridad - Validaciones expirando muestran solo las del owner logueado - Dashboard funciona para todos los roles --- ## FASE 10 — Attack Paths & Purple Team Avanzado **Duración estimada:** 3-4 semanas **Dependencias:** Fases 8, 9 --- ### Tarea 10.1: Modelo de Attack Paths **Qué:** Permitir definir escenarios de ataque encadenados (Initial Access → Execution → Persistence → Lateral Movement → Exfiltration) como entidades de primer nivel. **Implementación:** **app/models/attack_path.py** (nuevo): ```python import uuid import enum from datetime import datetime from sqlalchemy import ( Column, String, Integer, Float, DateTime, ForeignKey, Text, Boolean, Enum as SQLEnum ) from sqlalchemy.dialects.postgresql import UUID, JSONB from sqlalchemy.orm import relationship from app.database import Base class AttackPathStatus(str, enum.Enum): draft = "draft" ready = "ready" executing = "executing" completed = "completed" archived = "archived" class AttackPath(Base): """Escenario de ataque encadenado multi-fase.""" __tablename__ = "attack_paths" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) name = Column(String(300), nullable=False) description = Column(Text) threat_scenario = Column(Text) status = Column(SQLEnum(AttackPathStatus), default=AttackPathStatus.draft) threat_actor_id = Column(UUID(as_uuid=True), ForeignKey("threat_actors.id")) campaign_id = Column(UUID(as_uuid=True), ForeignKey("campaigns.id")) # Resultado detection_rate = Column(Float) mean_time_to_detect = Column(Float) furthest_step_reached = Column(Integer, default=0) complexity = Column(String(20), default="medium") target_environment = Column(String(200)) tags = Column(JSONB, default=[]) metadata = Column(JSONB, default={}) created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) started_at = Column(DateTime) completed_at = Column(DateTime) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) steps = relationship("AttackPathStep", back_populates="attack_path", order_by="AttackPathStep.step_order") class AttackPathStep(Base): """Un paso individual dentro de un attack path.""" __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) step_order = Column(Integer, nullable=False) name = Column(String(300), nullable=False) description = Column(Text) tactic = Column(String(100)) technique_id = Column(UUID(as_uuid=True), ForeignKey("techniques.id")) test_id = Column(UUID(as_uuid=True), ForeignKey("tests.id")) procedure = Column(Text) tools = Column(JSONB, default=[]) expected_detection = Column(Text) # Resultado executed_at = Column(DateTime) detected = Column(Boolean) detection_time_seconds = Column(Integer) detection_details = Column(Text) red_notes = Column(Text) blue_notes = Column(Text) events = Column(JSONB, default=[]) metadata = Column(JSONB, default={}) attack_path = relationship("AttackPath", back_populates="steps") ``` Services y routers: CRUD completo + endpoint de ejecución colaborativa + auto-generación de informe del ejercicio. **Verificación:** - Crear attack path con 5 pasos → pasos ordenados correctamente - Ejecutar paso a paso → `detection_rate` se recalcula automáticamente - `furthest_step_reached` refleja hasta dónde llegó sin detección - Completar path → genera timeline completa en `events` --- ### Tarea 10.2: Timeline Colaborativa **Qué:** Registro en tiempo real de acciones Red/Blue durante un ejercicio, con timestamps para medir tiempos de detección y respuesta. Este modelo ya existe parcialmente con los `events` JSONB en `AttackPathStep`. El servicio encapsula la lógica de agregar eventos y calcular métricas temporales. --- ## FASE 11 — Knowledge Management **Duración estimada:** 2-3 semanas **Dependencias:** Fase 8 --- ### Tarea 11.1: Modelo de Playbooks **Qué:** Cada técnica puede tener playbooks asociados: cómo atacar, cómo detectar, cómo investigar, cómo responder. **Implementación:** **app/models/playbook.py** (nuevo): ```python import uuid import enum from datetime import datetime from sqlalchemy import ( Column, String, Integer, DateTime, ForeignKey, Text, Boolean, Enum as SQLEnum ) from sqlalchemy.dialects.postgresql import UUID, JSONB from app.database import Base class PlaybookType(str, enum.Enum): attack = "attack" detect = "detect" investigate = "investigate" respond = "respond" hunt = "hunt" class Playbook(Base): __tablename__ = "playbooks" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) technique_id = Column(UUID(as_uuid=True), ForeignKey("techniques.id"), nullable=False, index=True) playbook_type = Column(SQLEnum(PlaybookType), nullable=False) title = Column(String(300), nullable=False) content_markdown = Column(Text, nullable=False) version = Column(Integer, default=1) is_published = Column(Boolean, default=False) last_reviewed_at = Column(DateTime) last_reviewed_by = Column(UUID(as_uuid=True), ForeignKey("users.id")) difficulty = Column(String(20)) estimated_time_minutes = Column(Integer) tools_required = Column(JSONB, default=[]) prerequisites = Column(JSONB, default=[]) references = Column(JSONB, default=[]) tags = Column(JSONB, default=[]) created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) class LessonLearned(Base): """Registro inmutable de lecciones aprendidas.""" __tablename__ = "lessons_learned" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) technique_id = Column(UUID(as_uuid=True), ForeignKey("techniques.id"), index=True) test_id = Column(UUID(as_uuid=True), ForeignKey("tests.id")) attack_path_id = Column(UUID(as_uuid=True), ForeignKey("attack_paths.id")) campaign_id = Column(UUID(as_uuid=True), ForeignKey("campaigns.id")) title = Column(String(300), nullable=False) what_happened = Column(Text, nullable=False) what_failed = Column(Text) root_cause = Column(Text) how_fixed = Column(Text) recommendations = Column(Text) impact = Column(String(20)) tags = Column(JSONB, default=[]) created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) created_at = Column(DateTime, default=datetime.utcnow) ``` Routers y servicios: CRUD con soporte Markdown, búsqueda fulltext, versionado. **Verificación:** - Crear playbook tipo "detect" para T1059 → asociado correctamente - Editar contenido → `version` incrementa, contenido anterior preservado en audit log - Buscar playbooks por técnica retorna todos los tipos - Lessons learned vinculadas a test/campaign correctamente --- ## FASE 12 — Risk Intelligence & Recomendaciones **Duración estimada:** 2-3 semanas **Dependencias:** Fases 4 (OsintItem), 8, 9 --- ### Tarea 12.1: Risk Score Compuesto por Técnica **Qué:** Score de riesgo operativo real que combina: explotabilidad, frecuencia en amenazas reales, tiempo sin validar, cobertura de detección, y severidad. **Implementación:** **app/services/risk_scoring_service.py** (nuevo): ```python def calculate_technique_risk(db: Session, technique_id: UUID) -> dict: """Calcula un risk score multidimensional por técnica.""" technique = db.query(Technique).filter(Technique.id == technique_id).first() if not technique: return None # 1. EXPLOITABILITY (0-100) templates = db.query(func.count(TestTemplate.id)).filter( TestTemplate.technique_id == technique_id ).scalar() or 0 osint_count = db.query(func.count(OsintItem.id)).filter( OsintItem.technique_id == technique_id ).scalar() or 0 exploitability = min(100, templates * 15 + osint_count * 10) # 2. THREAT_FREQUENCY (0-100) actor_count = db.query(func.count(ThreatActorTechnique.id)).filter( ThreatActorTechnique.technique_id == technique_id ).scalar() or 0 threat_frequency = min(100, actor_count * 20) # 3. DETECTION_GAP (0-100): inverso del confidence score confidence = db.query(TechniqueConfidenceScore).filter( TechniqueConfidenceScore.technique_id == technique_id ).first() confidence_score = confidence.confidence_score if confidence else 0 detection_gap = 100 - confidence_score # 4. STALENESS (0-100) last_validated = confidence.last_validated_at if confidence else None if not last_validated: staleness = 100 else: days = (datetime.utcnow() - last_validated).days staleness = min(100, days / 3.65) # 5. SEVERITY (0-100): basado en táctica tactic_severity = { "initial-access": 90, "execution": 85, "persistence": 80, "privilege-escalation": 85, "defense-evasion": 90, "credential-access": 85, "discovery": 50, "lateral-movement": 80, "collection": 60, "command-and-control": 75, "exfiltration": 90, "impact": 95, "resource-development": 40, "reconnaissance": 35, } severity = tactic_severity.get(technique.tactic, 50) # RISK SCORE COMPUESTO risk_score = ( exploitability * 0.15 + threat_frequency * 0.25 + detection_gap * 0.25 + staleness * 0.15 + severity * 0.20 ) return { "technique_id": str(technique_id), "mitre_id": technique.mitre_id, "risk_score": round(risk_score, 1), "risk_level": ( "critical" if risk_score >= 80 else "high" if risk_score >= 60 else "medium" if risk_score >= 40 else "low" ), "factors": { "exploitability": round(exploitability, 1), "threat_frequency": round(threat_frequency, 1), "detection_gap": round(detection_gap, 1), "staleness": round(staleness, 1), "severity": severity, }, } ``` --- ### Tarea 12.2: Motor de Recomendaciones **Qué:** Sistema que sugiere automáticamente acciones prioritarias. **Categorías de recomendaciones:** - Técnicas de alto riesgo sin cobertura - Técnicas nunca probadas usadas por actores relevantes - Detecciones silenciosas que necesitan verificación - Reglas huérfanas sin owner - Gaps de cobertura por táctica - Técnicas con muchos CVEs recientes sin validación El servicio consulta múltiples fuentes de datos y genera una lista priorizada de recomendaciones con acciones sugeridas. --- ## FASE 13 — Alertas Inteligentes Operativas **Duración estimada:** 2-3 semanas **Dependencias:** Fases 6 (webhooks como canal), 8, 9, 12 --- ### Tarea 13.1: Sistema de Alertas Operativas **Qué:** Alertas basadas en reglas configurables que detectan condiciones operativas y notifican proactivamente. **Modelo:** ```python class OperationalAlertRule(Base): __tablename__ = "operational_alert_rules" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) name = Column(String(300), nullable=False) description = Column(Text) rule_type = Column(String(100), nullable=False) condition = Column(JSONB, nullable=False) severity = Column(String(20), default="medium") is_active = Column(Boolean, default=True) notification_channels = Column(JSONB, default=["in_app"]) # ["in_app", "email", "webhook"] ← usa Fase 6 webhooks y Fase 7 email last_triggered_at = Column(DateTime) trigger_count = Column(Integer, default=0) created_at = Column(DateTime, default=datetime.utcnow) ``` **Job evaluador** — Ejecuta cada hora, evalúa todas las reglas activas, genera alertas cuando se cumplen condiciones. **Reglas pre-configuradas (seed):** | Regla | Condición | Severidad | |-------|-----------|-----------| | 3+ técnicas críticas llevan 90 días sin validarse | `stale_critical_techniques: threshold_days=90, min=3` | critical | | EDR actualizado — revalidaciones pendientes | `edr_update_pending_revalidation` | high | | Nueva técnica MITRE sin cobertura | `new_mitre_techniques_uncovered` | medium | | Regresión de cobertura detectada | `coverage_regression: drop_pct=5` | high | | Ola de validaciones expirando esta semana | `validation_expiry_wave: days=7, min=10` | medium | --- ## FASE 14 — Enterprise Readiness (SSO + API Keys) **Duración estimada:** 2 semanas **Dependencias:** Fase 0 --- ### Tarea 14.1: API Key Management **Qué:** Permitir crear API keys para integraciones automatizadas (PowerBI, SOAR, scripts) sin usar JWT de usuario. **Modelo:** ```python class ApiKey(Base): __tablename__ = "api_keys" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) name = Column(String(200), nullable=False) key_hash = Column(String(64), nullable=False, unique=True) # SHA256 key_prefix = Column(String(8)) # Primeros 8 chars para identificación visual user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) scopes = Column(JSONB, default=["read"]) is_active = Column(Boolean, default=True) last_used_at = Column(DateTime) expires_at = Column(DateTime) created_at = Column(DateTime, default=datetime.utcnow) ``` **Flujo:** Al crear → generar key aleatoria → mostrar UNA vez al usuario → guardar solo hash. Autenticar via header `X-API-Key`. --- ### Tarea 14.2: SSO/SAML Integration **Qué:** Soporte para Single Sign-On vía SAML 2.0 usando `python3-saml`. Configuración en `app/config.py` con `SAML_ENABLED`, `SAML_IDP_METADATA_URL`, etc. Endpoint `/auth/saml/login` y `/auth/saml/callback`. --- ## Features Adicionales Recomendadas Estas no estaban en los requisitos originales pero beneficiarían la plataforma: | # | Feature | Razón | Fase sugerida | |---|---------|-------|---------------| | A1 | Dashboard personalizable por rol | CISO ve métricas ejecutivas, Red Tech ve sus tests pendientes | Fase 5 | | A2 | Import/Export de ATT&CK Navigator layers | Los equipos ya usan Navigator | Fase 2 | | A3 | Approval workflow para cambios de scoring weights | Evitar que un admin cambie pesos sin supervisión | Fase 3 | | A4 | Tags y campos custom | Cada organización tiene taxonomías propias | Fase 5 | | A5 | Bulk operations | Validar/rechazar múltiples tests a la vez, asignar campaña masiva | Fases 5 y 9 | | A6 | Markdown support en descripciones | Los técnicos quieren formatear procedimientos | Fase 11 | | A7 | Detection Rule Git Sync | Sincronizar reglas desde repo Git corporativo | Fase 8 | | A8 | Coverage Heatmap con Confidence overlay | Heatmap muestra estado + confidence como segunda capa | Fase 8 | | A9 | Automatic Detection Gap → Ticket pipeline | RT rompe algo → auto-crear item en cola → asignar a Blue | Fase 10 | | A10 | Export/Import ATT&CK Navigator con Confidence | Exportar layer que incluye confidence level | Fase 8 | | A11 | Comparative Attack Path Results | Comparar mismo path ejecutado en diferentes fechas | Fase 10 | | A12 | SLA Tracking para Detection Gaps | Medir tiempo desde gap hasta implementación de regla | Fase 13 | --- ## Checklist de Nuevas Dependencias Python ``` # Fase 0 redis>=5.0.0 ruff # dev pytest # dev # Fase 1 atlassian-python-api>=4.0.0 tempo-api-python-client>=0.8.0 # Fase 2 weasyprint>=62.0 docxtpl>=0.18.0 # Fase 7 # smtplib es stdlib — no necesita instalar # Fase 11 markdown>=3.6 Pygments>=2.18 # Fase 14 python3-saml>=1.16.0 ``` --- ## Checklist de Verificación Global por Fase | Fase | Tests Mínimos Requeridos | |------|-------------------------| | 0 | Redis ping, token blacklist persistencia, índices en EXPLAIN, excepciones de dominio mapean a HTTP, CI pasa | | 1 | Jira link CRUD, sync bidireccional, worklog integridad hash, Tempo mock | | 2 | PDF genera con datos reales, DOCX abre en Word, analytics endpoints retornan JSON plano | | 3 | Audit log con IP/hash, login attempts registrados, password validation, rate limiting 429 | | 4 | CVEs importados sin duplicados, stale detection marca técnicas, rate limit NVD respetado | | 5 | Scoring con recencia funciona, pesos persisten en BD, snapshots con desglose por táctica | | 6 | Webhook recibe POST con signature HMAC válida, failure_count incrementa | | 7 | Email enviado con SMTP, skip silencioso sin SMTP, preferencias por usuario | | 8 | Assets CRUD, hash auto-generation, decay engine run, confidence calculation, infrastructure change invalidation | | 9 | Ownership assignment, revalidation queue generation, analyst dashboard data correctness | | 10 | Attack path creation, step execution, timeline recording, detection rate calculation | | 11 | Playbook CRUD, Markdown rendering, lessons learned linking | | 12 | Risk score calculation, recommendation generation, ranking correctness | | 13 | Alert rule evaluation, trigger conditions, notification dispatch via múltiples canales | | 14 | API key auth flow, SAML login flow | --- ## Instrucciones para Claude Code Para cada tarea, Claude Code debe seguir este flujo: 1. Leer el contexto de la tarea y los archivos existentes relevantes 2. Crear los archivos nuevos indicados 3. Modificar los archivos existentes según las instrucciones 4. Crear migración Alembic si hay cambios de BD 5. Ejecutar `alembic upgrade head` para verificar migración 6. Ejecutar `pytest tests/` para verificar que tests existentes no se rompen 7. Crear tests nuevos para la funcionalidad añadida 8. Verificar según los criterios de verificación de la tarea 9. Commit con mensaje descriptivo: `feat(detection-lifecycle): add decay engine service [FASE-8.3]` **Importante:** Cada tarea debe funcionar independientemente antes de pasar a la siguiente. No avanzar si la verificación no pasa.