v1.15.1 - modulo de reportes implementado
- Nuevo módulo de reportes: backend/app/api/v1/endpoints/reports.py - Schemas de reportes: backend/app/api/schemas/reports.py - Frontend: frontend-internal/src/routes/reports/ - Mejoras al módulo de auditoría (audit.py, audit_helpers.py) - Modelo de auditoría actualizado - Sidebar actualizado con enlace a reportes
This commit is contained in:
@@ -1,8 +1,34 @@
|
||||
"""Helper functions for audit endpoints"""
|
||||
"""
|
||||
Audit Helpers - ServiceManagerWeb
|
||||
===================================
|
||||
Funciones auxiliares reutilizables para los endpoints de auditoría.
|
||||
|
||||
Este archivo contiene:
|
||||
- audit_log_to_dict: Convierte un modelo AuditLog a diccionario
|
||||
- apply_tenant_filter: Aplica filtro de tenant según permisos
|
||||
- get_count_stat: Cuenta registros con filtros opcionales (CORREGIDO)
|
||||
- get_top_items: Obtiene los items más frecuentes
|
||||
- detect_mass_deletions: Detecta eliminaciones masivas sospechosas
|
||||
- detect_brute_force: Detecta ataques de fuerza bruta
|
||||
- detect_privilege_escalation: Detecta escaladas de privilegios
|
||||
|
||||
CORRECCIÓN APLICADA en get_count_stat:
|
||||
La columna created_at en PostgreSQL es 'timestamp with time zone' (TIMESTAMPTZ),
|
||||
lo que significa que almacena y devuelve fechas CON información de timezone (+00).
|
||||
|
||||
El bug era que se comparaba un datetime naive (sin timezone) contra una columna
|
||||
TIMESTAMPTZ. PostgreSQL no puede comparar ambos tipos directamente, por lo que
|
||||
el filtro se ignoraba silenciosamente y los tres contadores devolvían el mismo
|
||||
valor (el total histórico completo sin ningún filtro de fecha).
|
||||
|
||||
La solución es garantizar que TODAS las fechas que se usen en queries tengan
|
||||
timezone info (aware datetime en UTC) usando _ensure_aware_utc().
|
||||
"""
|
||||
|
||||
from sqlalchemy import select, func, and_, or_, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional, Dict, List
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
|
||||
from app.models.audit import AuditLog
|
||||
@@ -10,8 +36,18 @@ from app.models.user import User, UserRole
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONVERSIÓN DE MODELOS
|
||||
# =============================================================================
|
||||
|
||||
def audit_log_to_dict(log: AuditLog) -> dict:
|
||||
"""Convierte AuditLog a diccionario de respuesta"""
|
||||
"""
|
||||
Convierte un objeto AuditLog de SQLAlchemy a un diccionario plano
|
||||
compatible con los schemas de respuesta de Pydantic.
|
||||
|
||||
Incluye los datos del usuario relacionado si están cargados
|
||||
(requiere que la query use selectinload(AuditLog.user)).
|
||||
"""
|
||||
log_dict = {
|
||||
"id": log.id,
|
||||
"tenant_id": log.tenant_id,
|
||||
@@ -19,203 +55,463 @@ def audit_log_to_dict(log: AuditLog) -> dict:
|
||||
"action": log.action,
|
||||
"resource_type": log.resource_type,
|
||||
"resource_id": log.resource_id,
|
||||
# ip_address puede ser un objeto especial de PostgreSQL, convertir a string
|
||||
"ip_address": str(log.ip_address) if log.ip_address else None,
|
||||
"user_agent": log.user_agent,
|
||||
"correlation_id": log.correlation_id,
|
||||
"old_values": log.old_values,
|
||||
"new_values": log.new_values,
|
||||
# extra_metadata evita conflicto con la palabra reservada 'metadata'
|
||||
"metadata": log.extra_metadata,
|
||||
"created_at": log.created_at,
|
||||
"action_display": log.action_display,
|
||||
# Campos del usuario (se llenan abajo si la relación está cargada)
|
||||
"user_email": None,
|
||||
"user_name": None
|
||||
"user_name": None,
|
||||
"user_role": None,
|
||||
}
|
||||
|
||||
|
||||
# Solo agregar datos del usuario si la relación fue cargada en la query
|
||||
if log.user:
|
||||
log_dict["user_email"] = log.user.email
|
||||
log_dict["user_name"] = log.user.full_name
|
||||
log_dict["user_role"] = log.user.role.value if hasattr(log.user.role, 'value') else str(log.user.role)
|
||||
|
||||
# El rol puede ser un Enum de Python o un string, manejar ambos casos
|
||||
log_dict["user_role"] = (
|
||||
log.user.role.value
|
||||
if hasattr(log.user.role, 'value')
|
||||
else str(log.user.role)
|
||||
)
|
||||
|
||||
return log_dict
|
||||
|
||||
|
||||
def apply_tenant_filter(query, current_user: User, current_tenant: Tenant, all_tenants: bool = False, specific_tenant_id: Optional[uuid.UUID] = None):
|
||||
"""Aplica filtro de tenant según permisos del usuario"""
|
||||
# =============================================================================
|
||||
# FILTRO DE MULTI-TENANCY
|
||||
# =============================================================================
|
||||
|
||||
def apply_tenant_filter(
|
||||
query,
|
||||
current_user: User,
|
||||
current_tenant: Tenant,
|
||||
all_tenants: bool = False,
|
||||
specific_tenant_id: Optional[uuid.UUID] = None
|
||||
):
|
||||
"""
|
||||
Aplica el filtro de tenant a una query de SQLAlchemy según los
|
||||
permisos del usuario actual.
|
||||
|
||||
Reglas:
|
||||
- ADMIN y SUPPORT_MANAGER pueden ver todos los tenants si
|
||||
all_tenants=True, o filtrar por un tenant específico.
|
||||
- Cualquier otro rol solo puede ver los datos de su propio tenant.
|
||||
"""
|
||||
can_see_all_tenants = current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]
|
||||
|
||||
|
||||
if all_tenants and can_see_all_tenants:
|
||||
return query # No filtrar por tenant
|
||||
# Usuario privilegiado pidiendo ver todos los tenants → sin filtro
|
||||
return query
|
||||
elif specific_tenant_id and can_see_all_tenants:
|
||||
# Usuario privilegiado pidiendo un tenant específico
|
||||
return query.where(AuditLog.tenant_id == specific_tenant_id)
|
||||
else:
|
||||
# Cualquier otro caso → solo ver el propio tenant
|
||||
return query.where(AuditLog.tenant_id == current_tenant.id)
|
||||
|
||||
|
||||
async def get_count_stat(db: AsyncSession, tenant_id: Optional[uuid.UUID] = None,
|
||||
date_from: Optional[datetime] = None, action_filter=None) -> int:
|
||||
"""Obtiene estadística de conteo con filtros opcionales"""
|
||||
# =============================================================================
|
||||
# UTILIDAD DE FECHAS
|
||||
# =============================================================================
|
||||
|
||||
def _ensure_aware_utc(dt: datetime) -> datetime:
|
||||
"""
|
||||
Garantiza que un datetime tenga información de timezone en UTC.
|
||||
|
||||
PROBLEMA QUE RESUELVE:
|
||||
La columna created_at en PostgreSQL es 'timestamp with time zone'
|
||||
(TIMESTAMPTZ). Cuando se compara con un datetime naive (sin timezone),
|
||||
PostgreSQL no puede hacer la comparación correctamente y el filtro
|
||||
de fecha se ignora silenciosamente, devolviendo todos los registros
|
||||
sin importar la fecha.
|
||||
|
||||
SOLUCIÓN:
|
||||
Siempre convertir las fechas a aware UTC antes de usarlas en queries.
|
||||
|
||||
Casos que maneja:
|
||||
- datetime naive (sin tzinfo): agrega UTC como timezone
|
||||
- datetime aware (con tzinfo): convierte a UTC si es otra zona horaria
|
||||
|
||||
Ejemplos:
|
||||
datetime(2026, 2, 24, 15, 0, 0) → datetime(2026, 2, 24, 15, 0, 0, tzinfo=UTC)
|
||||
datetime(2026, 2, 24, 9, 0, 0, tzinfo=CST) → datetime(2026, 2, 24, 15, 0, 0, tzinfo=UTC)
|
||||
"""
|
||||
if dt.tzinfo is None:
|
||||
# Datetime naive → asumir que ya es UTC y agregarle timezone info
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
# Datetime aware → convertir a UTC (por si viene en otra zona horaria)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONTADORES DE ESTADÍSTICAS
|
||||
# =============================================================================
|
||||
|
||||
async def get_count_stat(
|
||||
db: AsyncSession,
|
||||
tenant_id: Optional[uuid.UUID] = None,
|
||||
date_from: Optional[datetime] = None,
|
||||
action_filter=None
|
||||
) -> int:
|
||||
"""
|
||||
Cuenta registros de AuditLog con filtros opcionales.
|
||||
|
||||
Usado por get_audit_stats() para calcular:
|
||||
- total_actions: Sin date_from → cuenta todos los registros
|
||||
- actions_today: date_from = now - 24h → registros del día
|
||||
- actions_this_week: date_from = now - 7d → registros de la semana
|
||||
|
||||
CORRECCIÓN: Las fechas se convierten a aware UTC con _ensure_aware_utc()
|
||||
antes de usarlas en la query, para que sean compatibles con la columna
|
||||
TIMESTAMPTZ de PostgreSQL y el filtro se aplique correctamente.
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos
|
||||
tenant_id: Si se especifica, filtra por ese tenant
|
||||
date_from: Si se especifica, solo cuenta registros desde esa fecha
|
||||
action_filter: Condición SQLAlchemy adicional opcional
|
||||
|
||||
Returns:
|
||||
Número entero de registros que cumplen los filtros
|
||||
"""
|
||||
query = select(func.count()).select_from(AuditLog)
|
||||
|
||||
|
||||
if tenant_id:
|
||||
query = query.where(AuditLog.tenant_id == tenant_id)
|
||||
|
||||
if date_from:
|
||||
query = query.where(AuditLog.created_at >= date_from)
|
||||
# CORRECCIÓN: convertir a aware UTC para compatibilidad con TIMESTAMPTZ
|
||||
# Sin esto, el filtro se ignora y los tres contadores son idénticos
|
||||
date_from_aware = _ensure_aware_utc(date_from)
|
||||
query = query.where(AuditLog.created_at >= date_from_aware)
|
||||
|
||||
if action_filter is not None:
|
||||
query = query.where(action_filter)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_top_items(db: AsyncSession, field, tenant_id: Optional[uuid.UUID] = None,
|
||||
limit: int = 5, join_user: bool = False) -> Dict[str, int]:
|
||||
"""Obtiene top items por campo con conteo"""
|
||||
# =============================================================================
|
||||
# ITEMS MÁS FRECUENTES
|
||||
# =============================================================================
|
||||
|
||||
async def get_top_items(
|
||||
db: AsyncSession,
|
||||
field,
|
||||
tenant_id: Optional[uuid.UUID] = None,
|
||||
limit: int = 5,
|
||||
join_user: bool = False
|
||||
) -> Dict[str, int]:
|
||||
"""
|
||||
Obtiene los valores más frecuentes de un campo, ordenados por conteo.
|
||||
|
||||
Ejemplos de uso:
|
||||
- get_top_items(db, AuditLog.action, ...) → {"ticket.create": 45}
|
||||
- get_top_items(db, AuditLog.resource_type, ...) → {"ticket": 60}
|
||||
- get_top_items(db, None, ..., join_user=True) → {"admin@empresa.com": 40}
|
||||
|
||||
Args:
|
||||
db: Sesión de base de datos
|
||||
field: Campo de AuditLog por el que agrupar
|
||||
tenant_id: Si se especifica, filtra por ese tenant
|
||||
limit: Máximo de resultados a devolver (por defecto 5)
|
||||
join_user: Si True, agrupa por email de usuario
|
||||
|
||||
Returns:
|
||||
Diccionario {valor: conteo} ordenado de mayor a menor
|
||||
"""
|
||||
if join_user:
|
||||
query = select(User.email, func.count(AuditLog.id).label('count')).join(User, AuditLog.user_id == User.id)
|
||||
# Modo usuarios: hacer JOIN con tabla User y agrupar por email
|
||||
query = (
|
||||
select(User.email, func.count(AuditLog.id).label('count'))
|
||||
.join(User, AuditLog.user_id == User.id)
|
||||
)
|
||||
else:
|
||||
# Modo campo: agrupar por el campo especificado
|
||||
query = select(field, func.count(AuditLog.id).label('count'))
|
||||
|
||||
|
||||
if tenant_id:
|
||||
query = query.where(AuditLog.tenant_id == tenant_id)
|
||||
|
||||
if not join_user:
|
||||
query = query.group_by(field)
|
||||
else:
|
||||
|
||||
if join_user:
|
||||
query = query.group_by(User.email)
|
||||
|
||||
else:
|
||||
query = query.group_by(field)
|
||||
|
||||
query = query.order_by(desc('count')).limit(limit)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
return {row[0]: row[1] for row in result}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DETECTORES DE INCIDENTES DE SEGURIDAD
|
||||
# =============================================================================
|
||||
|
||||
def detect_mass_deletions(logs: List[AuditLog], now: datetime) -> List[dict]:
|
||||
"""Detecta eliminaciones masivas de logs de auditoría"""
|
||||
"""
|
||||
Detecta patrones de eliminación masiva agrupando por usuario y día.
|
||||
|
||||
Lógica:
|
||||
- Agrupa todos los logs de eliminación por (usuario, día)
|
||||
- Si un usuario eliminó >= 3 recursos en un día, genera un incidente
|
||||
- La severidad escala según la cantidad:
|
||||
- >= 3 eliminaciones → medium
|
||||
- >= 5 eliminaciones → high
|
||||
- >= 10 eliminaciones → critical
|
||||
|
||||
El estado del incidente es:
|
||||
- "active": si la última eliminación fue hace menos de 24 horas
|
||||
- "resolved": si fue hace más de 24 horas
|
||||
"""
|
||||
# Agrupar eliminaciones por usuario y día
|
||||
deletion_groups = {}
|
||||
|
||||
|
||||
for log in logs:
|
||||
if not log.user:
|
||||
continue
|
||||
|
||||
|
||||
key = f"{log.user.email}_{log.created_at.date()}"
|
||||
|
||||
if key not in deletion_groups:
|
||||
deletion_groups[key] = {
|
||||
'user': log.user.email, 'date': log.created_at.date(),
|
||||
'count': 0, 'logs': [], 'first_seen': log.created_at, 'last_seen': log.created_at
|
||||
'user': log.user.email,
|
||||
'date': log.created_at.date(),
|
||||
'count': 0,
|
||||
'logs': [],
|
||||
'first_seen': log.created_at,
|
||||
'last_seen': log.created_at
|
||||
}
|
||||
|
||||
|
||||
deletion_groups[key]['count'] += 1
|
||||
deletion_groups[key]['logs'].append(log)
|
||||
deletion_groups[key]['first_seen'] = min(deletion_groups[key]['first_seen'], log.created_at)
|
||||
deletion_groups[key]['last_seen'] = max(deletion_groups[key]['last_seen'], log.created_at)
|
||||
|
||||
|
||||
incidents = []
|
||||
|
||||
for key, group in deletion_groups.items():
|
||||
if group['count'] >= 3:
|
||||
severity = "critical" if group['count'] >= 10 else "high" if group['count'] >= 5 else "medium"
|
||||
status = "active" if (now - group['last_seen']).days <= 1 else "resolved"
|
||||
|
||||
incidents.append({
|
||||
"id": f"mass_del_{key.replace('_', '-')}",
|
||||
"title": f"Eliminaciones masivas - {group['user']}",
|
||||
"description": f"{group['user']} eliminó {group['count']} elementos el {group['date']}",
|
||||
"severity": severity,
|
||||
"status": status,
|
||||
"incident_type": "mass_deletion",
|
||||
"affected_user": group['user'],
|
||||
"source_ip": str(group['logs'][0].ip_address) if group['logs'][0].ip_address else None,
|
||||
"evidence": [f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}" for log in group['logs'][:5]],
|
||||
"metadata": {
|
||||
"total_deletions": group['count'],
|
||||
"resource_types": list(set(log.resource_type for log in group['logs'])),
|
||||
"time_span_minutes": int((group['last_seen'] - group['first_seen']).total_seconds() / 60)
|
||||
},
|
||||
"created_at": group['first_seen'],
|
||||
"updated_at": group['last_seen']
|
||||
})
|
||||
|
||||
if group['count'] < 3:
|
||||
continue
|
||||
|
||||
if group['count'] >= 10:
|
||||
severity = "critical"
|
||||
elif group['count'] >= 5:
|
||||
severity = "high"
|
||||
else:
|
||||
severity = "medium"
|
||||
|
||||
# Convertir ambas fechas a aware UTC para comparación segura
|
||||
now_aware = _ensure_aware_utc(now)
|
||||
last_seen_aware = _ensure_aware_utc(group['last_seen'])
|
||||
hours_since_last = (now_aware - last_seen_aware).total_seconds() / 3600
|
||||
incident_status = "active" if hours_since_last <= 24 else "resolved"
|
||||
|
||||
incidents.append({
|
||||
"id": f"mass_del_{key.replace('_', '-')}",
|
||||
"title": f"Eliminaciones masivas - {group['user']}",
|
||||
"description": (
|
||||
f"{group['user']} elimino {group['count']} elementos "
|
||||
f"el {group['date']}"
|
||||
),
|
||||
"severity": severity,
|
||||
"status": incident_status,
|
||||
"incident_type": "mass_deletion",
|
||||
"affected_user": group['user'],
|
||||
"source_ip": (
|
||||
str(group['logs'][0].ip_address)
|
||||
if group['logs'][0].ip_address
|
||||
else None
|
||||
),
|
||||
"evidence": [
|
||||
f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}"
|
||||
for log in group['logs'][:5]
|
||||
],
|
||||
"metadata": {
|
||||
"total_deletions": group['count'],
|
||||
"resource_types": list(set(log.resource_type for log in group['logs'])),
|
||||
"time_span_minutes": int(
|
||||
(group['last_seen'] - group['first_seen']).total_seconds() / 60
|
||||
)
|
||||
},
|
||||
"created_at": group['first_seen'],
|
||||
"updated_at": group['last_seen']
|
||||
})
|
||||
|
||||
return incidents
|
||||
|
||||
|
||||
def detect_brute_force(logs: List[AuditLog], now: datetime) -> List[dict]:
|
||||
"""Detecta ataques de fuerza bruta de logs de login fallido"""
|
||||
"""
|
||||
Detecta ataques de fuerza bruta agrupando intentos fallidos por IP.
|
||||
|
||||
Lógica:
|
||||
- Agrupa todos los intentos fallidos de login por dirección IP
|
||||
- Si una IP tiene >= 5 intentos, genera un incidente
|
||||
- La severidad escala según la cantidad:
|
||||
- >= 5 intentos → medium
|
||||
- >= 10 intentos → high
|
||||
- >= 20 intentos → critical
|
||||
|
||||
El estado del incidente es:
|
||||
- "active": si el último intento fue hace menos de 24 horas
|
||||
- "investigating": si fue hace más de 24 horas
|
||||
"""
|
||||
ip_groups = {}
|
||||
|
||||
|
||||
for log in logs:
|
||||
if not log.ip_address:
|
||||
continue
|
||||
|
||||
|
||||
ip = str(log.ip_address)
|
||||
|
||||
if ip not in ip_groups:
|
||||
ip_groups[ip] = {'count': 0, 'logs': [], 'first_seen': log.created_at, 'last_seen': log.created_at, 'users': set()}
|
||||
|
||||
ip_groups[ip] = {
|
||||
'count': 0,
|
||||
'logs': [],
|
||||
'first_seen': log.created_at,
|
||||
'last_seen': log.created_at,
|
||||
'users': set()
|
||||
}
|
||||
|
||||
ip_groups[ip]['count'] += 1
|
||||
ip_groups[ip]['logs'].append(log)
|
||||
ip_groups[ip]['first_seen'] = min(ip_groups[ip]['first_seen'], log.created_at)
|
||||
ip_groups[ip]['last_seen'] = max(ip_groups[ip]['last_seen'], log.created_at)
|
||||
|
||||
if log.user and log.user.email:
|
||||
ip_groups[ip]['users'].add(log.user.email)
|
||||
|
||||
|
||||
incidents = []
|
||||
|
||||
for ip, group in ip_groups.items():
|
||||
if group['count'] >= 5:
|
||||
severity = "critical" if group['count'] >= 20 else "high" if group['count'] >= 10 else "medium"
|
||||
status = "active" if (now - group['last_seen']).total_seconds() <= 86400 else "investigating"
|
||||
|
||||
incidents.append({
|
||||
"id": f"brute_force_{ip.replace('.', '-')}",
|
||||
"title": f"Posible ataque de fuerza bruta desde {ip}",
|
||||
"description": f"Se detectaron {group['count']} intentos fallidos de login desde la IP {ip}",
|
||||
"severity": severity,
|
||||
"status": status,
|
||||
"incident_type": "brute_force_attack",
|
||||
"affected_user": ', '.join(list(group['users'])[:3]) if group['users'] else None,
|
||||
"source_ip": ip,
|
||||
"evidence": [f"Login fallido - {log.user.email if log.user else 'Unknown'} - {log.created_at.strftime('%H:%M:%S')}" for log in group['logs'][:5]],
|
||||
"metadata": {
|
||||
"total_attempts": group['count'],
|
||||
"targeted_users": list(group['users']),
|
||||
"time_span_hours": int((group['last_seen'] - group['first_seen']).total_seconds() / 3600)
|
||||
},
|
||||
"created_at": group['first_seen'],
|
||||
"updated_at": group['last_seen']
|
||||
})
|
||||
|
||||
if group['count'] < 5:
|
||||
continue
|
||||
|
||||
if group['count'] >= 20:
|
||||
severity = "critical"
|
||||
elif group['count'] >= 10:
|
||||
severity = "high"
|
||||
else:
|
||||
severity = "medium"
|
||||
|
||||
# Convertir ambas fechas a aware UTC para comparación segura
|
||||
now_aware = _ensure_aware_utc(now)
|
||||
last_seen_aware = _ensure_aware_utc(group['last_seen'])
|
||||
seconds_since_last = (now_aware - last_seen_aware).total_seconds()
|
||||
incident_status = "active" if seconds_since_last <= 86400 else "investigating"
|
||||
|
||||
incidents.append({
|
||||
"id": f"brute_force_{ip.replace('.', '-')}",
|
||||
"title": f"Posible ataque de fuerza bruta desde {ip}",
|
||||
"description": (
|
||||
f"Se detectaron {group['count']} intentos fallidos de "
|
||||
f"login desde la IP {ip}"
|
||||
),
|
||||
"severity": severity,
|
||||
"status": incident_status,
|
||||
"incident_type": "brute_force_attack",
|
||||
"affected_user": (
|
||||
', '.join(list(group['users'])[:3])
|
||||
if group['users']
|
||||
else None
|
||||
),
|
||||
"source_ip": ip,
|
||||
"evidence": [
|
||||
f"Login fallido - "
|
||||
f"{log.user.email if log.user else 'Desconocido'} - "
|
||||
f"{log.created_at.strftime('%H:%M:%S')}"
|
||||
for log in group['logs'][:5]
|
||||
],
|
||||
"metadata": {
|
||||
"total_attempts": group['count'],
|
||||
"targeted_users": list(group['users']),
|
||||
"time_span_hours": int(
|
||||
(group['last_seen'] - group['first_seen']).total_seconds() / 3600
|
||||
)
|
||||
},
|
||||
"created_at": group['first_seen'],
|
||||
"updated_at": group['last_seen']
|
||||
})
|
||||
|
||||
return incidents
|
||||
|
||||
|
||||
def detect_privilege_escalation(logs: List[AuditLog]) -> List[dict]:
|
||||
"""Detecta escaladas de privilegios"""
|
||||
role_hierarchy = {'CLIENT_USER': 1, 'CLIENT_ADMIN': 2, 'AGENT': 3, 'SUPPORT_MANAGER': 4, 'ADMIN': 5}
|
||||
"""
|
||||
Detecta escaladas de privilegios comparando el rol anterior y nuevo.
|
||||
|
||||
Lógica:
|
||||
- Analiza cada log de cambio de rol (user.update con campo 'role')
|
||||
- Si el nuevo rol tiene más privilegios que el anterior, es sospechoso
|
||||
- Cada cambio que represente una escalada genera un incidente
|
||||
|
||||
Jerarquía de roles (de menor a mayor privilegio):
|
||||
CLIENT_USER(1) < CLIENT_ADMIN(2) < AGENT(3) < SUPPORT_MANAGER(4) < ADMIN(5)
|
||||
"""
|
||||
role_hierarchy = {
|
||||
'CLIENT_USER': 1,
|
||||
'CLIENT_ADMIN': 2,
|
||||
'AGENT': 3,
|
||||
'SUPPORT_MANAGER': 4,
|
||||
'ADMIN': 5
|
||||
}
|
||||
|
||||
incidents = []
|
||||
|
||||
|
||||
for log in logs:
|
||||
if not log.user or not log.new_values or 'role' not in log.new_values:
|
||||
continue
|
||||
|
||||
|
||||
old_role = log.old_values.get('role') if log.old_values else 'Unknown'
|
||||
new_role = log.new_values.get('role')
|
||||
|
||||
old_level = role_hierarchy.get(old_role, 0)
|
||||
new_level = role_hierarchy.get(new_role, 0)
|
||||
|
||||
if new_level > old_level:
|
||||
incidents.append({
|
||||
"id": f"priv_esc_{log.id}",
|
||||
"title": f"Escalada de privilegios - {log.user.email}",
|
||||
"description": f"Usuario {log.user.email} cambió de rol {old_role} a {new_role}",
|
||||
"severity": "high" if new_role in ['ADMIN', 'SUPPORT_MANAGER'] else "medium",
|
||||
"status": "investigating",
|
||||
"incident_type": "privilege_escalation",
|
||||
"affected_user": log.user.email,
|
||||
"source_ip": str(log.ip_address) if log.ip_address else None,
|
||||
"evidence": [f"Cambio de rol: {old_role} → {new_role} - {log.created_at.strftime('%Y-%m-%d %H:%M')}"],
|
||||
"metadata": {
|
||||
"old_role": old_role,
|
||||
"new_role": new_role,
|
||||
"correlation_id": str(log.correlation_id) if log.correlation_id else None
|
||||
},
|
||||
"created_at": log.created_at,
|
||||
"updated_at": log.created_at
|
||||
})
|
||||
|
||||
return incidents
|
||||
|
||||
# Solo generar incidente si el nuevo rol tiene MÁS privilegios
|
||||
if new_level <= old_level:
|
||||
continue
|
||||
|
||||
severity = "high" if new_role in ['ADMIN', 'SUPPORT_MANAGER'] else "medium"
|
||||
|
||||
incidents.append({
|
||||
"id": f"priv_esc_{log.id}",
|
||||
"title": f"Escalada de privilegios - {log.user.email}",
|
||||
"description": (
|
||||
f"Usuario {log.user.email} cambio de rol "
|
||||
f"{old_role} a {new_role}"
|
||||
),
|
||||
"severity": severity,
|
||||
"status": "investigating",
|
||||
"incident_type": "privilege_escalation",
|
||||
"affected_user": log.user.email,
|
||||
"source_ip": str(log.ip_address) if log.ip_address else None,
|
||||
"evidence": [
|
||||
f"Cambio de rol: {old_role} → {new_role} - "
|
||||
f"{log.created_at.strftime('%Y-%m-%d %H:%M')}"
|
||||
],
|
||||
"metadata": {
|
||||
"old_role": old_role,
|
||||
"new_role": new_role,
|
||||
"correlation_id": (
|
||||
str(log.correlation_id)
|
||||
if log.correlation_id
|
||||
else None
|
||||
)
|
||||
},
|
||||
"created_at": log.created_at,
|
||||
"updated_at": log.created_at
|
||||
})
|
||||
|
||||
return incidents
|
||||
Reference in New Issue
Block a user