Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae0bfc9d62 | |||
| 0bc4caf65d | |||
| be762585d2 |
@@ -100,6 +100,38 @@ class SecurityActionResponse(BaseModel):
|
|||||||
action_id: Optional[UUID4] = Field(None, description="ID de la acción registrada")
|
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):
|
class AuditLogFilters(BaseModel):
|
||||||
"""
|
"""
|
||||||
Filtros para consulta de audit logs.
|
Filtros para consulta de audit logs.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select, func, and_, or_, desc
|
from sqlalchemy import select, func, and_, or_, desc
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
import uuid
|
import uuid
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
@@ -28,7 +28,9 @@ from app.api.schemas.audit import (
|
|||||||
SecurityAnalysisResponse,
|
SecurityAnalysisResponse,
|
||||||
SecurityThreatPattern,
|
SecurityThreatPattern,
|
||||||
SecurityActionRequest,
|
SecurityActionRequest,
|
||||||
SecurityActionResponse
|
SecurityActionResponse,
|
||||||
|
SecurityIncidentResponse,
|
||||||
|
SecurityIncidentListResponse
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -227,7 +229,7 @@ async def get_audit_stats(
|
|||||||
can_see_all=can_see_all_tenants
|
can_see_all=can_see_all_tenants
|
||||||
)
|
)
|
||||||
|
|
||||||
now = datetime.utcnow()
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
# Determinar si aplicar filtro de tenant
|
# Determinar si aplicar filtro de tenant
|
||||||
apply_tenant_filter = not (all_tenants and can_see_all_tenants)
|
apply_tenant_filter = not (all_tenants and can_see_all_tenants)
|
||||||
@@ -429,7 +431,7 @@ async def get_security_analysis(
|
|||||||
hours=hours
|
hours=hours
|
||||||
)
|
)
|
||||||
|
|
||||||
now = datetime.utcnow()
|
now = datetime.now(timezone.utc)
|
||||||
analysis_start = now - timedelta(hours=hours)
|
analysis_start = now - timedelta(hours=hours)
|
||||||
|
|
||||||
threats = []
|
threats = []
|
||||||
@@ -713,3 +715,537 @@ async def execute_security_action(
|
|||||||
message=message,
|
message=message,
|
||||||
action_id=None # TODO: Retornar ID del audit log creado
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { auth } from '$lib/stores/auth.js';
|
import { auth } from '$lib/stores/auth.js';
|
||||||
import Icon from './Icon.svelte';
|
import Icon from './Icon.svelte';
|
||||||
|
|
||||||
@@ -17,8 +18,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
auth.logout();
|
|
||||||
isMenuOpen = false;
|
isMenuOpen = false;
|
||||||
|
auth.logout(); // El store maneja la redirección automática
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { writable } from 'svelte/store';
|
|
||||||
import type { Writable } from 'svelte/store';
|
import type { Writable } from 'svelte/store';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface User {
|
export interface User {
|
||||||
@@ -49,13 +49,13 @@ function createAuthStore() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe,
|
subscribe,
|
||||||
|
|
||||||
// Initialize auth from localStorage
|
// Initialize auth from localStorage
|
||||||
init: () => {
|
init: () => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = localStorage.getItem('auth_token');
|
||||||
const user = localStorage.getItem('auth_user');
|
const user = localStorage.getItem('auth_user');
|
||||||
|
|
||||||
if (token && user) {
|
if (token && user) {
|
||||||
try {
|
try {
|
||||||
const parsedUser = JSON.parse(user);
|
const parsedUser = JSON.parse(user);
|
||||||
@@ -77,7 +77,7 @@ function createAuthStore() {
|
|||||||
// Login
|
// Login
|
||||||
login: async (credentials: LoginRequest): Promise<void> => {
|
login: async (credentials: LoginRequest): Promise<void> => {
|
||||||
update(state => ({ ...state, isLoading: true }));
|
update(state => ({ ...state, isLoading: true }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/auth/login', {
|
const response = await fetch('/api/v1/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -93,7 +93,7 @@ function createAuthStore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data: LoginResponse = await response.json();
|
const data: LoginResponse = await response.json();
|
||||||
|
|
||||||
// Store auth data
|
// Store auth data
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.setItem('auth_token', data.access_token);
|
localStorage.setItem('auth_token', data.access_token);
|
||||||
@@ -117,6 +117,8 @@ function createAuthStore() {
|
|||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
localStorage.removeItem('auth_user');
|
localStorage.removeItem('auth_user');
|
||||||
|
// Immediate redirect after cleanup
|
||||||
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
set(initialState);
|
set(initialState);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,9 +9,14 @@
|
|||||||
let logs = [];
|
let logs = [];
|
||||||
let stats = null;
|
let stats = null;
|
||||||
let users = [];
|
let users = [];
|
||||||
|
let incidents = [];
|
||||||
|
let securityAnalysis = null;
|
||||||
let isLoading = false;
|
let isLoading = false;
|
||||||
|
let isLoadingIncidents = false;
|
||||||
let selectedLog = null;
|
let selectedLog = null;
|
||||||
|
let selectedIncident = null;
|
||||||
let showDetailModal = false;
|
let showDetailModal = false;
|
||||||
|
let showIncidentModal = false;
|
||||||
|
|
||||||
// Paginación
|
// Paginación
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
@@ -19,12 +24,24 @@
|
|||||||
let totalLogs = 0;
|
let totalLogs = 0;
|
||||||
const perPage = 20;
|
const perPage = 20;
|
||||||
|
|
||||||
|
// Paginación de incidentes
|
||||||
|
let incidentsPage = 1;
|
||||||
|
let incidentsTotalPages = 1;
|
||||||
|
let totalIncidents = 0;
|
||||||
|
const incidentsPerPage = 10;
|
||||||
|
|
||||||
// Filtros básicos
|
// Filtros básicos
|
||||||
let filterUserId = '';
|
let filterUserId = '';
|
||||||
let filterAction = '';
|
let filterAction = '';
|
||||||
let filterResourceType = '';
|
let filterResourceType = '';
|
||||||
let searchText = '';
|
let searchText = '';
|
||||||
|
|
||||||
|
// Filtros de incidentes
|
||||||
|
let filterSeverity = '';
|
||||||
|
let filterIncidentType = '';
|
||||||
|
let filterStatus = '';
|
||||||
|
let incidentSearchText = '';
|
||||||
|
|
||||||
// Filtro multi-tenant (solo para ADMIN/SUPPORT_MANAGER)
|
// Filtro multi-tenant (solo para ADMIN/SUPPORT_MANAGER)
|
||||||
let allTenants = false;
|
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
|
* Cargar logs de auditoría con filtros
|
||||||
*/
|
*/
|
||||||
@@ -288,13 +356,47 @@
|
|||||||
* Obtener color de badge según tipo de acción
|
* Obtener color de badge según tipo de acción
|
||||||
*/
|
*/
|
||||||
function getActionColor(action: string): string {
|
function getActionColor(action: string): string {
|
||||||
if (action.includes('login')) return 'bg-green-100 text-green-800';
|
if (action.includes('delete')) return 'bg-red-600 text-white';
|
||||||
if (action.includes('logout')) return 'bg-gray-100 text-gray-800';
|
if (action.includes('update')) return 'bg-blue-600 text-white';
|
||||||
if (action.includes('create')) return 'bg-blue-100 text-blue-800';
|
if (action.includes('login') || action.includes('logout')) return 'bg-indigo-600 text-white';
|
||||||
if (action.includes('update')) return 'bg-yellow-100 text-yellow-800';
|
if (action.includes('create')) return 'bg-green-600 text-white';
|
||||||
if (action.includes('delete')) return 'bg-red-100 text-red-800';
|
return 'bg-gray-600 text-white';
|
||||||
if (action.includes('assign')) return 'bg-purple-100 text-purple-800';
|
}
|
||||||
return 'bg-gray-100 text-gray-800';
|
|
||||||
|
/**
|
||||||
|
* Obtener color de severidad
|
||||||
|
*/
|
||||||
|
function getSeverityColor(severity: string): string {
|
||||||
|
switch(severity?.toLowerCase()) {
|
||||||
|
case 'critical':
|
||||||
|
return 'bg-red-600 text-white';
|
||||||
|
case 'high':
|
||||||
|
return 'bg-orange-600 text-white';
|
||||||
|
case 'medium':
|
||||||
|
return 'bg-yellow-500 text-white';
|
||||||
|
case 'low':
|
||||||
|
return 'bg-blue-600 text-white';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-600 text-white';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener color de estado
|
||||||
|
*/
|
||||||
|
function getStatusColor(status: string): string {
|
||||||
|
switch(status?.toLowerCase()) {
|
||||||
|
case 'active':
|
||||||
|
case 'open':
|
||||||
|
return 'bg-blue-600 text-white';
|
||||||
|
case 'resolved':
|
||||||
|
case 'closed':
|
||||||
|
return 'bg-green-600 text-white';
|
||||||
|
case 'investigating':
|
||||||
|
return 'bg-yellow-500 text-white';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-600 text-white';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -336,6 +438,58 @@
|
|||||||
return roleMap[role] || role;
|
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
|
* Inicializar datos
|
||||||
*/
|
*/
|
||||||
@@ -343,6 +497,8 @@
|
|||||||
loadStats();
|
loadStats();
|
||||||
loadUsers();
|
loadUsers();
|
||||||
loadLogs();
|
loadLogs();
|
||||||
|
loadIncidents();
|
||||||
|
loadSecurityAnalysis();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -353,7 +509,7 @@
|
|||||||
<h1 class="text-2xl font-semibold text-gray-900">Auditoría del Sistema</h1>
|
<h1 class="text-2xl font-semibold text-gray-900">Auditoría del Sistema</h1>
|
||||||
<p class="mt-1 text-sm text-gray-600">
|
<p class="mt-1 text-sm text-gray-600">
|
||||||
Registro de actividades •
|
Registro de actividades •
|
||||||
<span class="font-medium text-primary-600">
|
<span class="font-medium text-gray-800">
|
||||||
{periodFilter === 'today' ? 'Hoy' :
|
{periodFilter === 'today' ? 'Hoy' :
|
||||||
periodFilter === 'yesterday' ? 'Ayer' :
|
periodFilter === 'yesterday' ? 'Ayer' :
|
||||||
periodFilter === 'last7days' ? 'Últimos 7 días' :
|
periodFilter === 'last7days' ? 'Últimos 7 días' :
|
||||||
@@ -369,31 +525,31 @@
|
|||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
on:click={() => changePeriod('today')}
|
on:click={() => changePeriod('today')}
|
||||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {periodFilter === 'today' ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}"
|
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {periodFilter === 'today' ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
|
||||||
>
|
>
|
||||||
Hoy
|
Hoy
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
on:click={() => changePeriod('yesterday')}
|
on:click={() => changePeriod('yesterday')}
|
||||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {periodFilter === 'yesterday' ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}"
|
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {periodFilter === 'yesterday' ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
|
||||||
>
|
>
|
||||||
Ayer
|
Ayer
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
on:click={() => changePeriod('last7days')}
|
on:click={() => changePeriod('last7days')}
|
||||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {periodFilter === 'last7days' ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}"
|
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {periodFilter === 'last7days' ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
|
||||||
>
|
>
|
||||||
Últimos 7 días
|
Últimos 7 días
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
on:click={() => changePeriod('last30days')}
|
on:click={() => changePeriod('last30days')}
|
||||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {periodFilter === 'last30days' ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}"
|
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {periodFilter === 'last30days' ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
|
||||||
>
|
>
|
||||||
Últimos 30 días
|
Últimos 30 días
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
on:click={() => changePeriod('custom')}
|
on:click={() => changePeriod('custom')}
|
||||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {periodFilter === 'custom' ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}"
|
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {periodFilter === 'custom' ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
@@ -412,7 +568,7 @@
|
|||||||
id="custom-date-from"
|
id="custom-date-from"
|
||||||
bind:value={customDateFrom}
|
bind:value={customDateFrom}
|
||||||
on:change={applyFilters}
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -422,7 +578,7 @@
|
|||||||
id="custom-date-to"
|
id="custom-date-to"
|
||||||
bind:value={customDateTo}
|
bind:value={customDateTo}
|
||||||
on:change={applyFilters}
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -444,7 +600,7 @@
|
|||||||
loadLogs();
|
loadLogs();
|
||||||
loadStats();
|
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"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-sm font-medium text-gray-900">Ver todos los clientes</span>
|
<span class="text-sm font-medium text-gray-900">Ver todos los clientes</span>
|
||||||
@@ -453,7 +609,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{#if allTenants}
|
{#if allTenants}
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-200 text-gray-800">
|
||||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zM9 9a1 1 0 012 0v4a1 1 0 11-2 0V9zm1-5a1 1 0 100 2 1 1 0 000-2z" />
|
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zM9 9a1 1 0 012 0v4a1 1 0 11-2 0V9zm1-5a1 1 0 100 2 1 1 0 000-2z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -473,28 +629,28 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="bg-white rounded-lg shadow p-4">
|
<div class="bg-white rounded-lg shadow p-4">
|
||||||
<div class="text-sm text-gray-500">Hoy</div>
|
<div class="text-sm text-gray-500">Hoy</div>
|
||||||
<div class="text-2xl font-bold text-primary-600">{stats.actions_today}</div>
|
<div class="text-2xl font-bold text-gray-600">{stats.actions_today}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white rounded-lg shadow p-4">
|
<div class="bg-white rounded-lg shadow p-4">
|
||||||
<div class="text-sm text-gray-500">Esta Semana</div>
|
<div class="text-sm text-gray-500">Esta Semana</div>
|
||||||
<div class="text-2xl font-bold text-green-600">{stats.actions_this_week}</div>
|
<div class="text-2xl font-bold text-gray-600">{stats.actions_this_week}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
<div class="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
</svg>
|
</svg>
|
||||||
<div class="text-sm text-gray-500">Vulnerabilidad</div>
|
<div class="text-sm text-gray-500">Incidentes Criticos</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="text-2xl font-bold {stats.critical_actions_today > 10 ? 'text-red-600' : stats.critical_actions_today > 5 ? 'text-amber-600' : 'text-green-600'}">
|
<div class="text-2xl font-bold text-gray-800">
|
||||||
{stats.critical_actions_today}
|
{stats.critical_actions_today || 0}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-xs text-primary-600 hover:text-primary-700 font-medium flex items-center gap-1 px-2 py-1 rounded hover:bg-primary-50 transition-colors"
|
class="text-xs text-gray-600 hover:text-gray-800 font-medium flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100 transition-colors"
|
||||||
on:click={() => filterCriticalActions()}
|
on:click={() => filterCriticalActions()}
|
||||||
title="Filtrar acciones críticas"
|
title="Ver incidentes críticos"
|
||||||
>
|
>
|
||||||
Ver
|
Ver
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -502,7 +658,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500 mt-1">Acciones críticas hoy</div>
|
<div class="text-xs text-gray-500 mt-1">Incidentes críticos hoy</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -519,7 +675,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="text-sm font-medium text-gray-900">Filtros Avanzados</span>
|
<span class="text-sm font-medium text-gray-900">Filtros Avanzados</span>
|
||||||
{#if activeFiltersCount > 0}
|
{#if activeFiltersCount > 0}
|
||||||
<span class="px-2 py-0.5 rounded-full bg-primary-100 text-primary-700 text-xs font-medium">
|
<span class="px-2 py-0.5 rounded-full bg-gray-200 text-gray-700 text-xs font-medium">
|
||||||
{activeFiltersCount}
|
{activeFiltersCount}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -541,7 +697,7 @@
|
|||||||
bind:value={searchText}
|
bind:value={searchText}
|
||||||
on:input={applyFilters}
|
on:input={applyFilters}
|
||||||
placeholder="Buscar en acciones..."
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -552,7 +708,7 @@
|
|||||||
id="user"
|
id="user"
|
||||||
bind:value={filterUserId}
|
bind:value={filterUserId}
|
||||||
on:change={applyFilters}
|
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"
|
||||||
>
|
>
|
||||||
<option value="">Todos</option>
|
<option value="">Todos</option>
|
||||||
{#each users as user}
|
{#each users as user}
|
||||||
@@ -568,7 +724,7 @@
|
|||||||
id="action"
|
id="action"
|
||||||
bind:value={filterAction}
|
bind:value={filterAction}
|
||||||
on:change={applyFilters}
|
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"
|
||||||
>
|
>
|
||||||
<option value="">Todas</option>
|
<option value="">Todas</option>
|
||||||
{#each Array.from(availableActions).sort() as action}
|
{#each Array.from(availableActions).sort() as action}
|
||||||
@@ -584,7 +740,7 @@
|
|||||||
id="resource-type"
|
id="resource-type"
|
||||||
bind:value={filterResourceType}
|
bind:value={filterResourceType}
|
||||||
on:change={applyFilters}
|
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"
|
||||||
>
|
>
|
||||||
<option value="">Todos</option>
|
<option value="">Todos</option>
|
||||||
{#each Array.from(availableResourceTypes).sort() as resourceType}
|
{#each Array.from(availableResourceTypes).sort() as resourceType}
|
||||||
@@ -598,7 +754,7 @@
|
|||||||
<div class="mt-4 flex justify-end">
|
<div class="mt-4 flex justify-end">
|
||||||
<button
|
<button
|
||||||
on:click={clearFilters}
|
on:click={clearFilters}
|
||||||
class="text-sm text-primary-600 hover:text-primary-700 font-medium"
|
class="text-sm text-gray-600 hover:text-gray-800 font-medium"
|
||||||
>
|
>
|
||||||
Limpiar filtros
|
Limpiar filtros
|
||||||
</button>
|
</button>
|
||||||
@@ -608,6 +764,130 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sección de Incidentes de Seguridad -->
|
||||||
|
<div class="bg-white shadow rounded-lg mb-6">
|
||||||
|
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-medium text-gray-900">Incidentes de Seguridad</h3>
|
||||||
|
<span class="text-sm text-gray-500">{totalIncidents} incidentes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtros de Incidentes -->
|
||||||
|
<div class="px-4 py-3 border-b border-gray-200">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar incidentes..."
|
||||||
|
bind:value={incidentSearchText}
|
||||||
|
on:input={applyIncidentFilters}
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
bind:value={filterSeverity}
|
||||||
|
on:change={applyIncidentFilters}
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Toda severidad</option>
|
||||||
|
<option value="critical">Crítico</option>
|
||||||
|
<option value="high">Alto</option>
|
||||||
|
<option value="medium">Medio</option>
|
||||||
|
<option value="low">Bajo</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
bind:value={filterStatus}
|
||||||
|
on:change={applyIncidentFilters}
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-gray-500 focus:ring-gray-500 sm:text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Todo estado</option>
|
||||||
|
<option value="active">Activo</option>
|
||||||
|
<option value="investigating">Investigando</option>
|
||||||
|
<option value="resolved">Resuelto</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
on:click={clearIncidentFilters}
|
||||||
|
class="px-3 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Limpiar filtros
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lista de Incidentes -->
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
{#if isLoadingIncidents}
|
||||||
|
<div class="flex items-center justify-center p-8">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-600"></div>
|
||||||
|
<span class="ml-2 text-sm text-gray-500">Cargando incidentes...</span>
|
||||||
|
</div>
|
||||||
|
{:else if incidents.length === 0}
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900">No hay incidentes</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">No se encontraron incidentes de seguridad para los filtros seleccionados.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="divide-y divide-gray-200">
|
||||||
|
{#each incidents as incident (incident.id)}
|
||||||
|
<div class="p-4 hover:bg-gray-50 transition-colors cursor-pointer" on:click={() => viewIncidentDetail(incident)}>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate">{incident.title}</p>
|
||||||
|
<p class="text-sm text-gray-500 truncate">{incident.description || 'Sin descripción'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="px-2 py-1 text-xs font-medium rounded-full {getSeverityColor(incident.severity)}">
|
||||||
|
{incident.severity?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span class="px-2 py-1 text-xs font-medium rounded-full {getStatusColor(incident.status)}">
|
||||||
|
{incident.status?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
{formatSimpleDate(incident.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Paginación de Incidentes -->
|
||||||
|
{#if incidentsTotalPages > 1}
|
||||||
|
<div class="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
|
||||||
|
<div class="text-sm text-gray-700">
|
||||||
|
Página {incidentsPage} de {incidentsTotalPages}
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-1">
|
||||||
|
<button
|
||||||
|
on:click={() => goToIncidentsPage(incidentsPage - 1)}
|
||||||
|
disabled={incidentsPage === 1}
|
||||||
|
class="px-3 py-1 text-sm bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => goToIncidentsPage(incidentsPage + 1)}
|
||||||
|
disabled={incidentsPage === incidentsTotalPages}
|
||||||
|
class="px-3 py-1 text-sm bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tabla de Logs -->
|
<!-- Tabla de Logs -->
|
||||||
<div class="bg-white shadow rounded-lg overflow-hidden flex flex-col" style="max-height: calc(100vh - 500px); min-height: 400px;">
|
<div class="bg-white shadow rounded-lg overflow-hidden flex flex-col" style="max-height: calc(100vh - 500px); min-height: 400px;">
|
||||||
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
@@ -672,8 +952,8 @@
|
|||||||
<td class="px-3 py-2 text-sm">
|
<td class="px-3 py-2 text-sm">
|
||||||
{#if log.user_email}
|
{#if log.user_email}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex-shrink-0 w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
|
<div class="flex-shrink-0 w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center">
|
||||||
<span class="text-xs font-medium text-primary-700">
|
<span class="text-xs font-medium text-gray-700">
|
||||||
{(log.user_name || '?').charAt(0).toUpperCase()}
|
{(log.user_name || '?').charAt(0).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -701,7 +981,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
on:click={() => viewDetail(log)}
|
on:click={() => viewDetail(log)}
|
||||||
class="text-primary-600 hover:text-primary-900 font-medium transition-colors"
|
class="text-gray-600 hover:text-gray-900 font-medium transition-colors"
|
||||||
>
|
>
|
||||||
Ver
|
Ver
|
||||||
</button>
|
</button>
|
||||||
@@ -722,8 +1002,8 @@
|
|||||||
<div class="flex items-start gap-3 flex-1 min-w-0">
|
<div class="flex items-start gap-3 flex-1 min-w-0">
|
||||||
<!-- Avatar -->
|
<!-- Avatar -->
|
||||||
{#if log.user_email}
|
{#if log.user_email}
|
||||||
<div class="flex-shrink-0 w-10 h-10 bg-primary-100 rounded-full flex items-center justify-center">
|
<div class="flex-shrink-0 w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center">
|
||||||
<span class="text-sm font-medium text-primary-700">
|
<span class="text-xs font-medium text-gray-700">
|
||||||
{(log.user_name || '?').charAt(0).toUpperCase()}
|
{(log.user_name || '?').charAt(0).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -760,7 +1040,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
on:click={() => viewDetail(log)}
|
on:click={() => viewDetail(log)}
|
||||||
class="flex-shrink-0 text-primary-600 hover:text-primary-900 transition-colors p-1"
|
class="flex-shrink-0 text-gray-600 hover:text-gray-900 transition-colors p-1"
|
||||||
title="Ver detalles"
|
title="Ver detalles"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -835,7 +1115,7 @@
|
|||||||
{#each Array.from({length: Math.min(5, totalPages)}, (_, i) => i + Math.max(1, Math.min(currentPage - 2, totalPages - 4))) as page}
|
{#each Array.from({length: Math.min(5, totalPages)}, (_, i) => i + Math.max(1, Math.min(currentPage - 2, totalPages - 4))) as page}
|
||||||
<button
|
<button
|
||||||
on:click={() => goToPage(page)}
|
on:click={() => goToPage(page)}
|
||||||
class="relative inline-flex items-center px-3 py-1.5 border text-xs font-medium transition-colors {page === currentPage ? 'z-10 bg-primary-600 border-primary-600 text-white' : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'}"
|
class="relative inline-flex items-center px-3 py-1.5 border text-xs font-medium transition-colors {page === currentPage ? 'z-10 bg-indigo-600 border-indigo-600 text-white' : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'}"
|
||||||
>
|
>
|
||||||
{page}
|
{page}
|
||||||
</button>
|
</button>
|
||||||
@@ -873,8 +1153,99 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de Incidentes -->
|
||||||
|
{#if showIncidentModal && selectedIncident}
|
||||||
|
<Modal open={showIncidentModal} size="2xl" title="Detalle del Incidente de Seguridad" on:close={() => showIncidentModal = false}>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Información General -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-2">Información General</h4>
|
||||||
|
<dl class="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500">Título:</dt>
|
||||||
|
<dd class="text-gray-900">{selectedIncident.title}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500">Severidad:</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="px-2 py-1 text-xs font-medium rounded-full {getSeverityColor(selectedIncident.severity)}">
|
||||||
|
{selectedIncident.severity?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500">Estado:</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="px-2 py-1 text-xs font-medium rounded-full {getStatusColor(selectedIncident.status)}">
|
||||||
|
{selectedIncident.status?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500">Fecha:</dt>
|
||||||
|
<dd class="text-gray-900">{formatDate(selectedIncident.created_at)}</dd>
|
||||||
|
</div>
|
||||||
|
{#if selectedIncident.affected_user}
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500">Usuario Afectado:</dt>
|
||||||
|
<dd class="text-gray-900">{selectedIncident.affected_user}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if selectedIncident.source_ip}
|
||||||
|
<div>
|
||||||
|
<dt class="font-medium text-gray-500">IP Origen:</dt>
|
||||||
|
<dd class="text-gray-900 font-mono text-xs">{selectedIncident.source_ip}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Descripción -->
|
||||||
|
{#if selectedIncident.description}
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-2">Descripción</h4>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-3 text-sm text-gray-700">
|
||||||
|
{selectedIncident.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Evidencia -->
|
||||||
|
{#if selectedIncident.evidence && selectedIncident.evidence.length > 0}
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-2">Evidencia</h4>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-3">
|
||||||
|
<ul class="list-disc list-inside text-sm text-gray-700 space-y-1">
|
||||||
|
{#each selectedIncident.evidence as evidence}
|
||||||
|
<li>{evidence}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Metadata -->
|
||||||
|
{#if selectedIncident.metadata && Object.keys(selectedIncident.metadata).length > 0}
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-2">Información Adicional</h4>
|
||||||
|
<pre class="bg-gray-50 rounded-lg p-3 text-xs font-mono text-gray-600 overflow-auto max-h-40">{JSON.stringify(selectedIncident.metadata, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div slot="footer" class="flex justify-end">
|
||||||
|
<button
|
||||||
|
on:click={() => showIncidentModal = false}
|
||||||
|
class="px-4 py-2 bg-white border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cerrar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Modal de Detalle -->
|
<!-- Modal de Detalle -->
|
||||||
{#if showDetailModal && selectedLog}
|
{#if showDetailModal && selectedLog}}
|
||||||
<Modal open={showDetailModal} size="2xl" title="Detalle del Registro de Auditoría" on:close={() => showDetailModal = false}>
|
<Modal open={showDetailModal} size="2xl" title="Detalle del Registro de Auditoría" on:close={() => showDetailModal = false}>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- Información General -->
|
<!-- Información General -->
|
||||||
|
|||||||
@@ -62,10 +62,10 @@
|
|||||||
*/
|
*/
|
||||||
function getSeverityColor(severity: string) {
|
function getSeverityColor(severity: string) {
|
||||||
const colors: any = {
|
const colors: any = {
|
||||||
low: 'bg-blue-100 text-blue-800',
|
low: 'bg-blue-600 text-white',
|
||||||
medium: 'bg-yellow-100 text-yellow-800',
|
medium: 'bg-yellow-500 text-white',
|
||||||
high: 'bg-orange-100 text-orange-800',
|
high: 'bg-orange-600 text-white',
|
||||||
critical: 'bg-red-100 text-red-800'
|
critical: 'bg-red-600 text-white'
|
||||||
};
|
};
|
||||||
return colors[severity] || colors.low;
|
return colors[severity] || colors.low;
|
||||||
}
|
}
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
<h1 class="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||||
<svg class="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-8 h-8 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
</svg>
|
</svg>
|
||||||
Análisis de Seguridad
|
Análisis de Seguridad
|
||||||
@@ -190,7 +190,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
on:click={() => loadSecurityAnalysis()}
|
on:click={() => loadSecurityAnalysis()}
|
||||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 flex items-center gap-2"
|
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
@@ -211,19 +211,19 @@
|
|||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
on:click={() => changeAnalysisPeriod(24)}
|
on:click={() => changeAnalysisPeriod(24)}
|
||||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 24 ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}"
|
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 24 ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
|
||||||
>
|
>
|
||||||
Últimas 24 horas
|
Últimas 24 horas
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
on:click={() => changeAnalysisPeriod(48)}
|
on:click={() => changeAnalysisPeriod(48)}
|
||||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 48 ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}"
|
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 48 ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
|
||||||
>
|
>
|
||||||
Últimas 48 horas
|
Últimas 48 horas
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
on:click={() => changeAnalysisPeriod(168)}
|
on:click={() => changeAnalysisPeriod(168)}
|
||||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 168 ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}"
|
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {analysisHours === 168 ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-indigo-50'}"
|
||||||
>
|
>
|
||||||
Última semana
|
Última semana
|
||||||
</button>
|
</button>
|
||||||
@@ -232,7 +232,7 @@
|
|||||||
|
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<div class="flex justify-center items-center py-12">
|
<div class="flex justify-center items-center py-12">
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-600"></div>
|
||||||
</div>
|
</div>
|
||||||
{:else if analysis}
|
{:else if analysis}
|
||||||
<!-- Resumen de Riesgo -->
|
<!-- Resumen de Riesgo -->
|
||||||
@@ -257,9 +257,9 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-500">Amenazas Detectadas</p>
|
<p class="text-sm text-gray-500">Amenazas Detectadas</p>
|
||||||
<p class="text-2xl font-bold text-red-600">{analysis.total_threats_detected}</p>
|
<p class="text-2xl font-bold text-gray-800">{analysis.total_threats_detected}</p>
|
||||||
</div>
|
</div>
|
||||||
<svg class="w-10 h-10 text-red-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,9 +269,9 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-500">Intentos Fallidos</p>
|
<p class="text-sm text-gray-500">Intentos Fallidos</p>
|
||||||
<p class="text-2xl font-bold text-orange-600">{analysis.failed_login_attempts}</p>
|
<p class="text-2xl font-bold text-gray-700">{analysis.failed_login_attempts}</p>
|
||||||
</div>
|
</div>
|
||||||
<svg class="w-10 h-10 text-orange-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,9 +281,9 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-500">IPs Sospechosas</p>
|
<p class="text-sm text-gray-500">IPs Sospechosas</p>
|
||||||
<p class="text-2xl font-bold text-purple-600">{analysis.suspicious_ips_count}</p>
|
<p class="text-2xl font-bold text-gray-700">{analysis.suspicious_ips_count}</p>
|
||||||
</div>
|
</div>
|
||||||
<svg class="w-10 h-10 text-purple-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -293,9 +293,9 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-500">Acciones Críticas</p>
|
<p class="text-sm text-gray-500">Acciones Críticas</p>
|
||||||
<p class="text-2xl font-bold text-amber-600">{analysis.critical_actions_count}</p>
|
<p class="text-2xl font-bold text-gray-700">{analysis.critical_actions_count}</p>
|
||||||
</div>
|
</div>
|
||||||
<svg class="w-10 h-10 text-amber-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -304,17 +304,17 @@
|
|||||||
|
|
||||||
<!-- Recomendaciones Generales -->
|
<!-- Recomendaciones Generales -->
|
||||||
{#if analysis.recommended_actions && analysis.recommended_actions.length > 0}
|
{#if analysis.recommended_actions && analysis.recommended_actions.length > 0}
|
||||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<svg class="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-gray-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h4 class="text-sm font-semibold text-blue-900 mb-2">Acciones Recomendadas</h4>
|
<h4 class="text-sm font-semibold text-gray-900 mb-2">Acciones Recomendadas</h4>
|
||||||
<ul class="space-y-1">
|
<ul class="space-y-1">
|
||||||
{#each analysis.recommended_actions as action}
|
{#each analysis.recommended_actions as action}
|
||||||
<li class="text-sm text-blue-800 flex items-start gap-2">
|
<li class="text-sm text-gray-700 flex items-start gap-2">
|
||||||
<svg class="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 text-gray-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
{action}
|
{action}
|
||||||
@@ -332,12 +332,12 @@
|
|||||||
<h3 class="text-lg font-semibold text-gray-900">Amenazas Detectadas</h3>
|
<h3 class="text-lg font-semibold text-gray-900">Amenazas Detectadas</h3>
|
||||||
|
|
||||||
{#each analysis.threats as threat}
|
{#each analysis.threats as threat}
|
||||||
<div class="bg-white shadow rounded-lg p-6 border-l-4 {threat.severity === 'critical' ? 'border-red-500' : threat.severity === 'high' ? 'border-orange-500' : threat.severity === 'medium' ? 'border-yellow-500' : 'border-blue-500'}">
|
<div class="bg-white shadow rounded-lg p-6 border-l-4 {threat.severity === 'critical' ? 'border-gray-900' : threat.severity === 'high' ? 'border-gray-600' : threat.severity === 'medium' ? 'border-gray-400' : 'border-gray-200'}">
|
||||||
<!-- Header de Amenaza -->
|
<!-- Header de Amenaza -->
|
||||||
<div class="flex items-start justify-between mb-4">
|
<div class="flex items-start justify-between mb-4">
|
||||||
<div class="flex items-start gap-3 flex-1">
|
<div class="flex items-start gap-3 flex-1">
|
||||||
<div class="p-2 rounded-lg {threat.severity === 'critical' ? 'bg-red-100' : threat.severity === 'high' ? 'bg-orange-100' : threat.severity === 'medium' ? 'bg-yellow-100' : 'bg-blue-100'}">
|
<div class="p-2 rounded-lg {threat.severity === 'critical' ? 'bg-gray-100' : threat.severity === 'high' ? 'bg-gray-100' : threat.severity === 'medium' ? 'bg-gray-100' : 'bg-gray-50'}">
|
||||||
<svg class="w-6 h-6 {threat.severity === 'critical' ? 'text-red-600' : threat.severity === 'high' ? 'text-orange-600' : threat.severity === 'medium' ? 'text-yellow-600' : 'text-blue-600'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 {threat.severity === 'critical' ? 'text-gray-900' : threat.severity === 'high' ? 'text-gray-700' : threat.severity === 'medium' ? 'text-gray-600' : 'text-gray-500'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={getThreatIcon(threat.type)} />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={getThreatIcon(threat.type)} />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -453,12 +453,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- No hay amenazas -->
|
<!-- No hay amenazas -->
|
||||||
<div class="bg-green-50 border border-green-200 rounded-lg p-8 text-center">
|
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
|
||||||
<svg class="w-16 h-16 text-green-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-16 h-16 text-gray-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
</svg>
|
</svg>
|
||||||
<h3 class="text-lg font-semibold text-green-900 mb-2">Sistema Seguro</h3>
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">Sistema Seguro</h3>
|
||||||
<p class="text-sm text-green-700">No se detectaron amenazas en el período analizado</p>
|
<p class="text-sm text-gray-600">No se detectaron amenazas en el período analizado</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -486,7 +486,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
bind:value={actionTarget}
|
bind:value={actionTarget}
|
||||||
placeholder="IP o email del usuario"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -496,7 +496,7 @@
|
|||||||
bind:value={actionReason}
|
bind:value={actionReason}
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="Razón de la acción de seguridad"
|
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"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -508,7 +508,7 @@
|
|||||||
bind:value={actionDuration}
|
bind:value={actionDuration}
|
||||||
min="1"
|
min="1"
|
||||||
max="10080"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -516,13 +516,13 @@
|
|||||||
<div class="flex justify-end gap-3 pt-4 border-t">
|
<div class="flex justify-end gap-3 pt-4 border-t">
|
||||||
<button
|
<button
|
||||||
on:click={() => showActionModal = false}
|
on:click={() => showActionModal = false}
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||||
>
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
on:click={executeSecurityAction}
|
on:click={executeSecurityAction}
|
||||||
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
|
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
>
|
>
|
||||||
Ejecutar Acción
|
Ejecutar Acción
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,61 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||||
|
safelist: [
|
||||||
|
// Colores de acciones
|
||||||
|
'bg-red-600',
|
||||||
|
'bg-blue-600',
|
||||||
|
'bg-green-600',
|
||||||
|
'bg-indigo-600',
|
||||||
|
'bg-orange-600',
|
||||||
|
'bg-yellow-500',
|
||||||
|
'bg-gray-600',
|
||||||
|
// Colores de severidad/riesgo
|
||||||
|
'bg-red-100',
|
||||||
|
'bg-red-800',
|
||||||
|
'bg-orange-100',
|
||||||
|
'bg-orange-300',
|
||||||
|
'bg-orange-600',
|
||||||
|
'bg-orange-700',
|
||||||
|
'bg-orange-800',
|
||||||
|
'bg-yellow-100',
|
||||||
|
'bg-yellow-300',
|
||||||
|
'bg-yellow-800',
|
||||||
|
'bg-blue-100',
|
||||||
|
'bg-blue-300',
|
||||||
|
'bg-blue-800',
|
||||||
|
'bg-green-100',
|
||||||
|
'bg-green-300',
|
||||||
|
'bg-green-800',
|
||||||
|
// Bordes
|
||||||
|
'border-red-300',
|
||||||
|
'border-orange-300',
|
||||||
|
'border-yellow-300',
|
||||||
|
'border-blue-300',
|
||||||
|
'border-green-300',
|
||||||
|
// Text colors
|
||||||
|
'text-red-600',
|
||||||
|
'text-red-800',
|
||||||
|
'text-orange-600',
|
||||||
|
'text-orange-800',
|
||||||
|
'text-yellow-800',
|
||||||
|
'text-blue-600',
|
||||||
|
'text-blue-800',
|
||||||
|
'text-green-600',
|
||||||
|
'text-green-800',
|
||||||
|
'text-white',
|
||||||
|
// Hover states
|
||||||
|
'hover:bg-red-700',
|
||||||
|
'hover:bg-orange-700',
|
||||||
|
'hover:bg-blue-700',
|
||||||
|
'hover:bg-indigo-700',
|
||||||
|
'hover:bg-indigo-50',
|
||||||
|
// Focus rings
|
||||||
|
'focus:ring-red-500',
|
||||||
|
'focus:ring-orange-500',
|
||||||
|
'focus:ring-blue-500',
|
||||||
|
'focus:ring-indigo-500',
|
||||||
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
|||||||
Reference in New Issue
Block a user