From be762585d2410230422606fc697e8de3289e7fd5 Mon Sep 17 00:00:00 2001 From: icamarillo Date: Mon, 16 Feb 2026 12:45:33 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20Mejoras=20en=20auditor=C3=ADa=20-=20inc?= =?UTF-8?q?identes=20de=20seguridad=20y=20esquema=20de=20colores?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: * Agregado endpoint /v1/audit/security/incidents con paginación y filtros * Nuevos schemas SecurityIncidentResponse y SecurityIncidentListResponse * Fix timezone: datetime.utcnow() → datetime.now(timezone.utc) en 4 ubicaciones * Detección automática de incidentes: mass deletion, brute force, privilege escalation - Frontend (Internal): * Nueva sección de Incidentes de Seguridad con modal de detalles * Filtros por severidad, estado y tipo de incidente * Conversión completa a esquema grayscale (gray-100 a gray-900) * Eliminados todos los emojis de páginas audit y security * Implementada paginación para incidentes - Fixes: * Resuelto error 500: TypeError con datetimes timezone-aware/naive * Resuelto error 404: endpoint de incidentes faltante --- backend/app/api/schemas/audit.py | 32 ++ backend/app/api/v1/endpoints/audit.py | 544 +++++++++++++++++- .../src/lib/components/Header.svelte | 3 +- frontend-client/src/lib/stores/auth.ts | 12 +- .../src/routes/audit/+page.svelte | 453 +++++++++++++-- .../src/routes/audit/security/+page.svelte | 90 +-- 6 files changed, 1038 insertions(+), 96 deletions(-) diff --git a/backend/app/api/schemas/audit.py b/backend/app/api/schemas/audit.py index fcd101d..fdad5bd 100644 --- a/backend/app/api/schemas/audit.py +++ b/backend/app/api/schemas/audit.py @@ -100,6 +100,38 @@ class SecurityActionResponse(BaseModel): action_id: Optional[UUID4] = Field(None, description="ID de la acción registrada") +class SecurityIncidentResponse(BaseModel): + """Respuesta para incidentes de seguridad.""" + id: str = Field(description="ID único del incidente") + title: str = Field(description="Título del incidente") + description: Optional[str] = Field(None, description="Descripción detallada") + severity: str = Field(description="Severidad: low, medium, high, critical") + status: str = Field(description="Estado: active, investigating, resolved") + incident_type: str = Field(description="Tipo de incidente") + affected_user: Optional[str] = Field(None, description="Usuario afectado") + source_ip: Optional[str] = Field(None, description="IP origen del incidente") + evidence: list[str] = Field(default=[], description="Evidencia del incidente") + metadata: Optional[Dict[str, Any]] = Field(None, description="Metadata adicional") + created_at: datetime = Field(description="Fecha de creación") + updated_at: Optional[datetime] = Field(None, description="Última actualización") + resolved_at: Optional[datetime] = Field(None, description="Fecha de resolución") + + class Config: + from_attributes = True + + +class SecurityIncidentListResponse(BaseModel): + """Respuesta paginada de incidentes de seguridad.""" + incidents: list[SecurityIncidentResponse] + total: int = Field(description="Total de incidentes") + page: int = Field(description="Página actual") + per_page: int = Field(description="Incidentes por página") + total_pages: int = Field(description="Total de páginas") + + class Config: + from_attributes = True + + class AuditLogFilters(BaseModel): """ Filtros para consulta de audit logs. diff --git a/backend/app/api/v1/endpoints/audit.py b/backend/app/api/v1/endpoints/audit.py index 2ddea5d..cb8b77a 100644 --- a/backend/app/api/v1/endpoints/audit.py +++ b/backend/app/api/v1/endpoints/audit.py @@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, and_, or_, desc from sqlalchemy.orm import selectinload from typing import Optional, List -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import uuid import structlog @@ -28,7 +28,9 @@ from app.api.schemas.audit import ( SecurityAnalysisResponse, SecurityThreatPattern, SecurityActionRequest, - SecurityActionResponse + SecurityActionResponse, + SecurityIncidentResponse, + SecurityIncidentListResponse ) router = APIRouter() @@ -227,7 +229,7 @@ async def get_audit_stats( can_see_all=can_see_all_tenants ) - now = datetime.utcnow() + now = datetime.now(timezone.utc) # Determinar si aplicar filtro de tenant apply_tenant_filter = not (all_tenants and can_see_all_tenants) @@ -429,7 +431,7 @@ async def get_security_analysis( hours=hours ) - now = datetime.utcnow() + now = datetime.now(timezone.utc) analysis_start = now - timedelta(hours=hours) threats = [] @@ -713,3 +715,537 @@ async def execute_security_action( message=message, action_id=None # TODO: Retornar ID del audit log creado ) + + +@router.get("/security/incidents", response_model=SecurityIncidentListResponse) +async def get_security_incidents( + # Paginación + page: int = Query(default=1, ge=1, description="Número de página"), + per_page: int = Query(default=20, ge=1, le=100, description="Incidentes por página"), + + # Filtros + severity: Optional[str] = Query(None, description="Filtrar por severidad"), + status: Optional[str] = Query(None, description="Filtrar por estado"), + incident_type: Optional[str] = Query(None, description="Filtrar por tipo"), + search: Optional[str] = Query(None, description="Búsqueda en título o descripción"), + + # Multi-tenant (solo ADMIN/SUPPORT_MANAGER) + all_tenants: bool = Query(False, description="Ver incidentes de todos los tenants"), + + # Dependencies + current_user: User = Depends(require_auditor_role), + current_tenant: Tenant = Depends(get_current_tenant), + db: AsyncSession = Depends(get_db) +): + """ + Obtener incidentes de seguridad. + + Los incidentes se generan dinámicamente analizando logs de auditoría + para detectar patrones sospechosos y acciones críticas. + + **Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR + + **Retorna**: Lista paginada de incidentes de seguridad + """ + logger.info( + "Fetching security incidents", + user_id=str(current_user.id), + tenant_id=str(current_tenant.id), + filters={ + "severity": severity, + "status": status, + "type": incident_type, + "page": page, + "per_page": per_page + } + ) + + # Generar incidentes a partir de logs de auditoría + incidents = [] + now = datetime.now(timezone.utc) + + # Determinar rango de tiempo para análisis (últimos 7 días para mejor performance) + analysis_start = now - timedelta(days=7) + + # Construir query base + base_query = select(AuditLog).options( + selectinload(AuditLog.user) + ).where( + AuditLog.created_at >= analysis_start + ) + + # Aplicar filtro de tenant + if all_tenants and current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]: + # Ver incidentes de todos los tenants + pass + else: + base_query = base_query.where(AuditLog.tenant_id == current_tenant.id) + + # 1. DETECTAR ELIMINACIONES MASIVAS + deletion_query = base_query.where( + AuditLog.action.like('%.delete') + ).order_by(desc(AuditLog.created_at)) + + deletion_result = await db.execute(deletion_query) + deletion_logs = deletion_result.scalars().all() + + # Agrupar eliminaciones por usuario y fecha + deletion_groups = {} + for log in deletion_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 + } + + deletion_groups[key]['count'] += 1 + deletion_groups[key]['logs'].append(log) + if log.created_at < deletion_groups[key]['first_seen']: + deletion_groups[key]['first_seen'] = log.created_at + if log.created_at > deletion_groups[key]['last_seen']: + deletion_groups[key]['last_seen'] = log.created_at + + # Crear incidentes para eliminaciones masivas (>=3 eliminaciones) + for key, group in deletion_groups.items(): + if group['count'] >= 3: # Umbral para considerar "masivo" + severity = "critical" if group['count'] >= 10 else "high" if group['count'] >= 5 else "medium" + + incidents.append(SecurityIncidentResponse( + 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="active" if (now - group['last_seen']).days <= 1 else "resolved", + incident_type="mass_deletion", + affected_user=group['user'], + source_ip=group['logs'][0].ip_address, + evidence=[ + f"{log.action} - {log.resource_type} - {log.created_at.strftime('%H:%M:%S')}" + for log in group['logs'][:5] # Solo mostrar los primeros 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'] + )) + + # 2. DETECTAR INTENTOS DE LOGIN FALLIDOS + failed_login_query = base_query.where( + AuditLog.action == 'user.login_failed' + ).order_by(desc(AuditLog.created_at)) + + failed_login_result = await db.execute(failed_login_query) + failed_login_logs = failed_login_result.scalars().all() + + # Agrupar por IP + ip_groups = {} + for log in failed_login_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'] += 1 + ip_groups[ip]['logs'].append(log) + if log.created_at < ip_groups[ip]['first_seen']: + ip_groups[ip]['first_seen'] = log.created_at + if log.created_at > ip_groups[ip]['last_seen']: + ip_groups[ip]['last_seen'] = log.created_at + if log.user and log.user.email: + ip_groups[ip]['users'].add(log.user.email) + + # Crear incidentes para IPs con muchos fallos (>=5) + 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" + + incidents.append(SecurityIncidentResponse( + 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="active" if (now - group['last_seen']).total_seconds() <= 86400 else "investigating", # 24 horas + 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'] + )) + + # 3. DETECTAR CAMBIOS DE ROLES/PRIVILEGIOS + privilege_query = base_query.where( + and_( + AuditLog.action == 'user.update', + AuditLog.new_values.op('?')('role') + ) + ).order_by(desc(AuditLog.created_at)) + + privilege_result = await db.execute(privilege_query) + privilege_logs = privilege_result.scalars().all() + + for log in privilege_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') + + # Solo crear incidente si es escalada de privilegios + role_hierarchy = {'CLIENT_USER': 1, 'CLIENT_ADMIN': 2, 'AGENT': 3, 'SUPPORT_MANAGER': 4, 'ADMIN': 5} + old_level = role_hierarchy.get(old_role, 0) + new_level = role_hierarchy.get(new_role, 0) + + if new_level > old_level: + incidents.append(SecurityIncidentResponse( + 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=log.ip_address, + 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 + )) + + # Aplicar filtros de búsqueda + filtered_incidents = incidents + + if severity: + filtered_incidents = [i for i in filtered_incidents if i.severity == severity] + + if status: + filtered_incidents = [i for i in filtered_incidents if i.status == status] + + if incident_type: + filtered_incidents = [i for i in filtered_incidents if i.incident_type == incident_type] + + if search: + search_lower = search.lower() + filtered_incidents = [ + i for i in filtered_incidents + if search_lower in i.title.lower() or (i.description and search_lower in i.description.lower()) + ] + + # Ordenar por fecha de creación (más recientes primero) + filtered_incidents.sort(key=lambda x: x.created_at, reverse=True) + + # Aplicar paginación + total = len(filtered_incidents) + total_pages = (total + per_page - 1) // per_page + + start_idx = (page - 1) * per_page + end_idx = start_idx + per_page + paginated_incidents = filtered_incidents[start_idx:end_idx] + + return SecurityIncidentListResponse( + incidents=paginated_incidents, + total=total, + page=page, + per_page=per_page, + total_pages=total_pages + ) + + +@router.get("/security/incidents", response_model=SecurityIncidentListResponse) +async def get_security_incidents( + # Paginación + page: int = Query(default=1, ge=1, description="Número de página"), + per_page: int = Query(default=20, ge=1, le=100, description="Incidentes por página"), + + # Filtros + severity: Optional[str] = Query(None, description="Filtrar por severidad"), + status: Optional[str] = Query(None, description="Filtrar por estado"), + incident_type: Optional[str] = Query(None, description="Filtrar por tipo"), + search: Optional[str] = Query(None, description="Búsqueda en título o descripción"), + + # Multi-tenant (solo ADMIN/SUPPORT_MANAGER) + all_tenants: bool = Query(False, description="Ver incidentes de todos los tenants"), + + # Dependencies + current_user: User = Depends(require_auditor_role), + current_tenant: Tenant = Depends(get_current_tenant), + db: AsyncSession = Depends(get_db) +): + """ + Obtener incidentes de seguridad. + + Los incidentes se generan dinámicamente analizando logs de auditoría + para detectar patrones sospechosos y acciones críticas. + + **Permisos**: ADMIN, SUPPORT_MANAGER, AUDITOR + + **Retorna**: Lista paginada de incidentes de seguridad + """ + logger.info( + "Fetching security incidents", + user_id=str(current_user.id), + tenant_id=str(current_tenant.id), + filters={ + "severity": severity, + "status": status, + "type": incident_type, + "page": page, + "per_page": per_page + } + ) + + # Generar incidentes a partir de logs de auditoría + incidents = [] + now = datetime.now(timezone.utc) + + # Determinar rango de tiempo para análisis (últimos 30 días) + analysis_start = now - timedelta(days=30) + + # Construir query base + base_query = select(AuditLog).options( + selectinload(AuditLog.user) + ).where( + AuditLog.created_at >= analysis_start + ) + + # Aplicar filtro de tenant + if all_tenants and current_user.role in [UserRole.ADMIN, UserRole.SUPPORT_MANAGER]: + # Ver incidentes de todos los tenants + pass + else: + base_query = base_query.where(AuditLog.tenant_id == current_tenant.id) + + # 1. DETECTAR ELIMINACIONES MASIVAS + deletion_query = base_query.where( + AuditLog.action.like('%.delete') + ).order_by(desc(AuditLog.created_at)) + + deletion_result = await db.execute(deletion_query) + deletion_logs = deletion_result.scalars().all() + + # Agrupar eliminaciones por usuario y fecha + deletion_groups = {} + for log in deletion_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 + } + + deletion_groups[key]['count'] += 1 + deletion_groups[key]['logs'].append(log) + if log.created_at < deletion_groups[key]['first_seen']: + deletion_groups[key]['first_seen'] = log.created_at + if log.created_at > deletion_groups[key]['last_seen']: + deletion_groups[key]['last_seen'] = log.created_at + + # Crear incidentes para eliminaciones masivas (>=5 eliminaciones) + for key, group in deletion_groups.items(): + if group['count'] >= 5: # Umbral para considerar "masivo" + severity = "critical" if group['count'] >= 20 else "high" if group['count'] >= 10 else "medium" + + incidents.append(SecurityIncidentResponse( + id=f"mass_del_{key.replace('_', '-')}", + title=f"Eliminaciones masivas detectadas - {group['user']}", + description=f"{group['user']} eliminó {group['count']} elementos el {group['date']}", + severity=severity, + status="active" if (now - group['last_seen']).days <= 1 else "resolved", + incident_type="mass_deletion", + affected_user=group['user'], + source_ip=group['logs'][0].ip_address, + evidence=[ + f"{log.action} - {log.resource_type} {log.resource_id or 'N/A'} - {log.created_at.isoformat()}" + for log in group['logs'][:5] # Solo mostrar los primeros 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'] + )) + + # 2. DETECTAR INTENTOS DE LOGIN FALLIDOS + failed_login_query = base_query.where( + AuditLog.action == 'user.login_failed' + ).order_by(desc(AuditLog.created_at)) + + failed_login_result = await db.execute(failed_login_query) + failed_login_logs = failed_login_result.scalars().all() + + # Agrupar por IP + ip_groups = {} + for log in failed_login_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'] += 1 + ip_groups[ip]['logs'].append(log) + if log.created_at < ip_groups[ip]['first_seen']: + ip_groups[ip]['first_seen'] = log.created_at + if log.created_at > ip_groups[ip]['last_seen']: + ip_groups[ip]['last_seen'] = log.created_at + if log.user and log.user.email: + ip_groups[ip]['users'].add(log.user.email) + + # Crear incidentes para IPs con muchos fallos (>=10) + for ip, group in ip_groups.items(): + if group['count'] >= 10: + severity = "critical" if group['count'] >= 50 else "high" if group['count'] >= 25 else "medium" + + incidents.append(SecurityIncidentResponse( + 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="active" if (now - group['last_seen']).hours <= 24 else "investigating", + 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.isoformat()}" + for log in group['logs'][:10] + ], + 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'] + )) + + # 3. DETECTAR CAMBIOS DE ROLES/PRIVILEGIOS + privilege_query = base_query.where( + and_( + AuditLog.action == 'user.update', + AuditLog.new_values.op('?')('role') + ) + ).order_by(desc(AuditLog.created_at)) + + privilege_result = await db.execute(privilege_query) + privilege_logs = privilege_result.scalars().all() + + for log in privilege_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') + + # Solo crear incidente si es escalada de privilegios + role_hierarchy = {'CLIENT_USER': 1, 'CLIENT_ADMIN': 2, 'AGENT': 3, 'SUPPORT_MANAGER': 4, 'ADMIN': 5} + old_level = role_hierarchy.get(old_role, 0) + new_level = role_hierarchy.get(new_role, 0) + + if new_level > old_level: + incidents.append(SecurityIncidentResponse( + id=f"priv_esc_{log.id}", + title=f"Escalada de privilegios detectada - {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=log.ip_address, + evidence=[ + f"Cambio de rol: {old_role} → {new_role} - {log.created_at.isoformat()}" + ], + metadata={ + "old_role": old_role, + "new_role": new_role, + "changed_by": log.correlation_id # En el futuro, trackear quién hizo el cambio + }, + created_at=log.created_at, + updated_at=log.created_at + )) + + # Aplicar filtros de búsqueda + filtered_incidents = incidents + + if severity: + filtered_incidents = [i for i in filtered_incidents if i.severity == severity] + + if status: + filtered_incidents = [i for i in filtered_incidents if i.status == status] + + if incident_type: + filtered_incidents = [i for i in filtered_incidents if i.incident_type == incident_type] + + if search: + search_lower = search.lower() + filtered_incidents = [ + i for i in filtered_incidents + if search_lower in i.title.lower() or (i.description and search_lower in i.description.lower()) + ] + + # Ordenar por fecha de creación (más recientes primero) + filtered_incidents.sort(key=lambda x: x.created_at, reverse=True) + + # Aplicar paginación + total = len(filtered_incidents) + total_pages = (total + per_page - 1) // per_page + + start_idx = (page - 1) * per_page + end_idx = start_idx + per_page + paginated_incidents = filtered_incidents[start_idx:end_idx] + + return SecurityIncidentListResponse( + incidents=paginated_incidents, + total=total, + page=page, + per_page=per_page, + total_pages=total_pages + ) diff --git a/frontend-client/src/lib/components/Header.svelte b/frontend-client/src/lib/components/Header.svelte index 2ab1ab1..7152547 100644 --- a/frontend-client/src/lib/components/Header.svelte +++ b/frontend-client/src/lib/components/Header.svelte @@ -1,5 +1,6 @@ diff --git a/frontend-client/src/lib/stores/auth.ts b/frontend-client/src/lib/stores/auth.ts index 0823712..2a6263a 100644 --- a/frontend-client/src/lib/stores/auth.ts +++ b/frontend-client/src/lib/stores/auth.ts @@ -1,5 +1,5 @@ -import { writable } from 'svelte/store'; import type { Writable } from 'svelte/store'; +import { writable } from 'svelte/store'; // Types export interface User { @@ -49,13 +49,13 @@ function createAuthStore() { return { subscribe, - + // Initialize auth from localStorage init: () => { if (typeof window !== 'undefined') { const token = localStorage.getItem('auth_token'); const user = localStorage.getItem('auth_user'); - + if (token && user) { try { const parsedUser = JSON.parse(user); @@ -77,7 +77,7 @@ function createAuthStore() { // Login login: async (credentials: LoginRequest): Promise => { update(state => ({ ...state, isLoading: true })); - + try { const response = await fetch('/api/v1/auth/login', { method: 'POST', @@ -93,7 +93,7 @@ function createAuthStore() { } const data: LoginResponse = await response.json(); - + // Store auth data if (typeof window !== 'undefined') { localStorage.setItem('auth_token', data.access_token); @@ -117,6 +117,8 @@ function createAuthStore() { if (typeof window !== 'undefined') { localStorage.removeItem('auth_token'); localStorage.removeItem('auth_user'); + // Immediate redirect after cleanup + window.location.href = '/login'; } set(initialState); }, diff --git a/frontend-internal/src/routes/audit/+page.svelte b/frontend-internal/src/routes/audit/+page.svelte index a54e242..c47480e 100644 --- a/frontend-internal/src/routes/audit/+page.svelte +++ b/frontend-internal/src/routes/audit/+page.svelte @@ -9,9 +9,14 @@ let logs = []; let stats = null; let users = []; + let incidents = []; + let securityAnalysis = null; let isLoading = false; + let isLoadingIncidents = false; let selectedLog = null; + let selectedIncident = null; let showDetailModal = false; + let showIncidentModal = false; // Paginación let currentPage = 1; @@ -19,12 +24,24 @@ let totalLogs = 0; const perPage = 20; + // Paginación de incidentes + let incidentsPage = 1; + let incidentsTotalPages = 1; + let totalIncidents = 0; + const incidentsPerPage = 10; + // Filtros básicos let filterUserId = ''; let filterAction = ''; let filterResourceType = ''; let searchText = ''; + // Filtros de incidentes + let filterSeverity = ''; + let filterIncidentType = ''; + let filterStatus = ''; + let incidentSearchText = ''; + // Filtro multi-tenant (solo para ADMIN/SUPPORT_MANAGER) let allTenants = false; @@ -126,6 +143,57 @@ } } + /** + * Cargar incidentes de seguridad + */ + async function loadIncidents() { + isLoadingIncidents = true; + try { + const params: any = { + page: incidentsPage, + per_page: incidentsPerPage + }; + + // Aplicar filtros de incidentes + if (filterSeverity) params.severity = filterSeverity; + if (filterIncidentType) params.type = filterIncidentType; + if (filterStatus) params.status = filterStatus; + if (incidentSearchText) params.search = incidentSearchText; + + // Aplicar filtro multi-tenant si el usuario tiene permiso + if (allTenants && canSeeAllTenants) { + params.all_tenants = true; + } + + const response = await api.get('/audit/security/incidents', params); + + incidents = response.incidents || []; + totalIncidents = response.total || 0; + incidentsTotalPages = response.total_pages || 1; + incidentsPage = response.page || 1; + } catch (e) { + console.error('Error cargando incidentes:', e); + incidents = []; + } finally { + isLoadingIncidents = false; + } + } + + /** + * Cargar análisis de seguridad + */ + async function loadSecurityAnalysis() { + try { + const params: any = { hours: 24 }; + if (allTenants && canSeeAllTenants) { + params.all_tenants = true; + } + securityAnalysis = await api.get('/audit/security/analysis', params); + } catch (e) { + console.error('Error cargando análisis de seguridad:', e); + } + } + /** * Cargar logs de auditoría con filtros */ @@ -285,16 +353,50 @@ } /** - * Obtener color de badge según tipo de acción + * Obtener color de badge según tipo de acción (solo escala de grises) */ function getActionColor(action: string): string { - if (action.includes('login')) return 'bg-green-100 text-green-800'; - if (action.includes('logout')) return 'bg-gray-100 text-gray-800'; - if (action.includes('create')) return 'bg-blue-100 text-blue-800'; - if (action.includes('update')) return 'bg-yellow-100 text-yellow-800'; - if (action.includes('delete')) return 'bg-red-100 text-red-800'; - if (action.includes('assign')) return 'bg-purple-100 text-purple-800'; - return 'bg-gray-100 text-gray-800'; + if (action.includes('delete')) return 'bg-gray-800 text-white'; + if (action.includes('update')) return 'bg-gray-600 text-white'; + if (action.includes('login') || action.includes('logout')) return 'bg-gray-400 text-white'; + if (action.includes('create')) return 'bg-gray-300 text-gray-800'; + return 'bg-gray-200 text-gray-700'; + } + + /** + * Obtener color de severidad (escala de grises) + */ + function getSeverityColor(severity: string): string { + switch(severity?.toLowerCase()) { + case 'critical': + return 'bg-gray-900 text-white'; + case 'high': + return 'bg-gray-700 text-white'; + case 'medium': + return 'bg-gray-500 text-white'; + case 'low': + return 'bg-gray-300 text-gray-800'; + default: + return 'bg-gray-200 text-gray-700'; + } + } + + /** + * Obtener color de estado (escala de grises) + */ + function getStatusColor(status: string): string { + switch(status?.toLowerCase()) { + case 'active': + case 'open': + return 'bg-gray-800 text-white'; + case 'resolved': + case 'closed': + return 'bg-gray-400 text-white'; + case 'investigating': + return 'bg-gray-600 text-white'; + default: + return 'bg-gray-200 text-gray-700'; + } } /** @@ -336,6 +438,58 @@ return roleMap[role] || role; } + /** + * Ver detalle de un incidente + */ + function viewIncidentDetail(incident: any) { + selectedIncident = incident; + showIncidentModal = true; + } + + /** + * Aplicar filtros de incidentes y recargar desde página 1 + */ + function applyIncidentFilters() { + incidentsPage = 1; + loadIncidents(); + } + + /** + * Limpiar filtros de incidentes + */ + function clearIncidentFilters() { + filterSeverity = ''; + filterIncidentType = ''; + filterStatus = ''; + incidentSearchText = ''; + incidentsPage = 1; + loadIncidents(); + } + + /** + * Cambiar página de incidentes + */ + function goToIncidentsPage(page: number) { + if (page >= 1 && page <= incidentsTotalPages) { + incidentsPage = page; + loadIncidents(); + } + } + + /** + * Formatear fecha simple + */ + function formatSimpleDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString('es-MX', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + /** * Inicializar datos */ @@ -343,6 +497,8 @@ loadStats(); loadUsers(); loadLogs(); + loadIncidents(); + loadSecurityAnalysis(); }); @@ -353,7 +509,7 @@

Auditoría del Sistema

Registro de actividades • - + {periodFilter === 'today' ? 'Hoy' : periodFilter === 'yesterday' ? 'Ayer' : periodFilter === 'last7days' ? 'Últimos 7 días' : @@ -369,31 +525,31 @@

@@ -422,7 +578,7 @@ id="custom-date-to" bind:value={customDateTo} on:change={applyFilters} - class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm" + class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm" />
@@ -444,7 +600,7 @@ loadLogs(); loadStats(); }} - class="rounded border-gray-300 text-primary-600 shadow-sm focus:border-primary-500 focus:ring-primary-500 h-4 w-4 mr-3" + class="rounded border-gray-300 text-gray-600 shadow-sm focus:border-gray-500 focus:ring-gray-500 h-4 w-4 mr-3" />
Ver todos los clientes @@ -453,7 +609,7 @@
{#if allTenants} - + @@ -473,28 +629,28 @@
Hoy
-
{stats.actions_today}
+
{stats.actions_today}
Esta Semana
-
{stats.actions_this_week}
+
{stats.actions_this_week}
- + -
Vulnerabilidad
+
Incidentes Criticos
-
- {stats.critical_actions_today} +
+ {stats.critical_actions_today || 0}
-
Acciones críticas hoy
+
Incidentes críticos hoy
{/if} @@ -519,7 +675,7 @@ Filtros Avanzados {#if activeFiltersCount > 0} - + {activeFiltersCount} {/if} @@ -541,7 +697,7 @@ bind:value={searchText} on:input={applyFilters} placeholder="Buscar en acciones..." - class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm" + class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm" /> @@ -552,7 +708,7 @@ id="user" bind:value={filterUserId} on:change={applyFilters} - class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm" + class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm" > {#each users as user} @@ -568,7 +724,7 @@ id="action" bind:value={filterAction} on:change={applyFilters} - class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm" + class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm" > {#each Array.from(availableActions).sort() as action} @@ -584,7 +740,7 @@ id="resource-type" bind:value={filterResourceType} on:change={applyFilters} - class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm" + class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm" > {#each Array.from(availableResourceTypes).sort() as resourceType} @@ -598,7 +754,7 @@
@@ -608,6 +764,130 @@ {/if}
+ +
+
+
+

Incidentes de Seguridad

+ {totalIncidents} incidentes +
+
+ + +
+
+ + + + +
+
+ + +
+ {#if isLoadingIncidents} +
+
+ Cargando incidentes... +
+ {:else if incidents.length === 0} +
+ + + +

No hay incidentes

+

No se encontraron incidentes de seguridad para los filtros seleccionados.

+
+ {:else} +
+ {#each incidents as incident (incident.id)} +
viewIncidentDetail(incident)}> +
+
+
+ + + +
+
+

{incident.title}

+

{incident.description || 'Sin descripción'}

+
+
+
+ + {incident.severity?.toUpperCase()} + + + {incident.status?.toUpperCase()} + + + {formatSimpleDate(incident.created_at)} + +
+
+
+ {/each} +
+ + + {#if incidentsTotalPages > 1} +
+
+ Página {incidentsPage} de {incidentsTotalPages} +
+
+ + +
+
+ {/if} + {/if} +
+
+
@@ -672,8 +952,8 @@ {#if log.user_email}
-
- +
+ {(log.user_name || '?').charAt(0).toUpperCase()}
@@ -701,7 +981,7 @@ @@ -722,8 +1002,8 @@
{#if log.user_email} -
- +
+ {(log.user_name || '?').charAt(0).toUpperCase()}
@@ -760,7 +1040,7 @@
+ +{#if showIncidentModal && selectedIncident} + showIncidentModal = false}> +
+ +
+

Información General

+
+
+
Título:
+
{selectedIncident.title}
+
+
+
Severidad:
+
+ + {selectedIncident.severity?.toUpperCase()} + +
+
+
+
Estado:
+
+ + {selectedIncident.status?.toUpperCase()} + +
+
+
+
Fecha:
+
{formatDate(selectedIncident.created_at)}
+
+ {#if selectedIncident.affected_user} +
+
Usuario Afectado:
+
{selectedIncident.affected_user}
+
+ {/if} + {#if selectedIncident.source_ip} +
+
IP Origen:
+
{selectedIncident.source_ip}
+
+ {/if} +
+
+ + + {#if selectedIncident.description} +
+

Descripción

+
+ {selectedIncident.description} +
+
+ {/if} + + + {#if selectedIncident.evidence && selectedIncident.evidence.length > 0} +
+

Evidencia

+
+
    + {#each selectedIncident.evidence as evidence} +
  • {evidence}
  • + {/each} +
+
+
+ {/if} + + + {#if selectedIncident.metadata && Object.keys(selectedIncident.metadata).length > 0} +
+

Información Adicional

+
{JSON.stringify(selectedIncident.metadata, null, 2)}
+
+ {/if} +
+ +
+ +
+
+{/if} + -{#if showDetailModal && selectedLog} +{#if showDetailModal && selectedLog}} showDetailModal = false}>
diff --git a/frontend-internal/src/routes/audit/security/+page.svelte b/frontend-internal/src/routes/audit/security/+page.svelte index aa3b3e6..0397fe3 100644 --- a/frontend-internal/src/routes/audit/security/+page.svelte +++ b/frontend-internal/src/routes/audit/security/+page.svelte @@ -44,28 +44,28 @@ } /** - * Obtener color según nivel de riesgo + * Obtener color según nivel de riesgo (escala de grises) */ function getRiskColor(level: string) { const colors: any = { - safe: 'bg-green-100 text-green-800 border-green-300', - low: 'bg-blue-100 text-blue-800 border-blue-300', - medium: 'bg-yellow-100 text-yellow-800 border-yellow-300', - high: 'bg-orange-100 text-orange-800 border-orange-300', - critical: 'bg-red-100 text-red-800 border-red-300' + safe: 'bg-gray-100 text-gray-800 border-gray-300', + low: 'bg-gray-200 text-gray-800 border-gray-400', + medium: 'bg-gray-400 text-white border-gray-500', + high: 'bg-gray-600 text-white border-gray-700', + critical: 'bg-gray-900 text-white border-gray-900' }; return colors[level] || colors.low; } /** - * Obtener color de severidad de amenaza + * Obtener color de severidad de amenaza (escala de grises) */ function getSeverityColor(severity: string) { const colors: any = { - low: 'bg-blue-100 text-blue-800', - medium: 'bg-yellow-100 text-yellow-800', - high: 'bg-orange-100 text-orange-800', - critical: 'bg-red-100 text-red-800' + low: 'bg-gray-200 text-gray-800', + medium: 'bg-gray-400 text-white', + high: 'bg-gray-600 text-white', + critical: 'bg-gray-900 text-white' }; return colors[severity] || colors.low; } @@ -179,7 +179,7 @@

- + Análisis de Seguridad @@ -190,7 +190,7 @@

@@ -232,7 +232,7 @@ {#if isLoading}
-
+
{:else if analysis} @@ -257,9 +257,9 @@

Amenazas Detectadas

-

{analysis.total_threats_detected}

+

{analysis.total_threats_detected}

- +
@@ -269,9 +269,9 @@

Intentos Fallidos

-

{analysis.failed_login_attempts}

+

{analysis.failed_login_attempts}

- +
@@ -281,9 +281,9 @@

IPs Sospechosas

-

{analysis.suspicious_ips_count}

+

{analysis.suspicious_ips_count}

- +
@@ -293,9 +293,9 @@

Acciones Críticas

-

{analysis.critical_actions_count}

+

{analysis.critical_actions_count}

- +
@@ -304,17 +304,17 @@ {#if analysis.recommended_actions && analysis.recommended_actions.length > 0} -
+
- +
-

Acciones Recomendadas

+

Acciones Recomendadas

    {#each analysis.recommended_actions as action} -
  • - +
  • + {action} @@ -332,12 +332,12 @@

    Amenazas Detectadas

    {#each analysis.threats as threat} -
    +
    -
    - +
    +
    @@ -418,7 +418,7 @@ {#if threat.affected_ips.length > 0}
    {:else} -
    - +
    + -

    Sistema Seguro

    -

    No se detectaron amenazas en el período analizado

    +

    Sistema Seguro

    +

    No se detectaron amenazas en el período analizado

    {/if} {/if} @@ -486,7 +486,7 @@ type="text" bind:value={actionTarget} placeholder="IP o email del usuario" - class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm" + class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm" />
    @@ -496,7 +496,7 @@ bind:value={actionReason} rows="3" placeholder="Razón de la acción de seguridad" - class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm" + class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm" >
    @@ -508,7 +508,7 @@ bind:value={actionDuration} min="1" max="10080" - class="block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm" + class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm" />
    {/if} @@ -516,13 +516,13 @@