Plataforma de monitorización de seguridad M365. Consume la Graph Security API (alertas e incidentes Defender), enriquece con contexto de usuario y dispositivo, y notifica a través de SISAlerts y Jira.
Security Monitor v2 es un servicio Python que consume la Graph Security API (alertas e incidentes Defender), enriquece con contexto de usuario y dispositivo, y notifica a través de SISAlerts y Jira.
Polling cada 120s de security/alerts_v2 e incidents. Prefiltro por severidad mínima global. Enriquecimiento con perfil de usuario, manager, grupos y datos de dispositivo.
CRUD de filtros con lógica “first match wins”. Cada filtro determina si se notifica a SISAlerts y/o si se crea ticket Jira, con qué proyecto y tipo de issue.
Cada alerta o incidente puede generar tickets en dos proyectos Jira distintos simultáneamente. Los enlaces entre alertas e incidentes se gestionan independientemente por proyecto.
Informe HTML de postura M365 generado los días 1 y 15. Cubre sign-ins bloqueados, risk detections, alertas por categoría y usuarios en riesgo. Se adjunta a un ticket Jira.
Tabla notification_log con canales sisalerts, jira_create, jira_create_2, jira_link:{key}. Previene duplicados tras reinicios o errores transitorios.
El scheduler gestiona el polling de la Graph Security API y el informe de protección en el mismo proceso Python. Comparten DB, config y servicios de notificación.
🛡️ Flujo principal — Graph Security API
Cada ciclo de 120 segundos el scheduler obtiene alertas e incidentes nuevos, los enriquece, los persiste en SQLite y los notifica según los filtros de severidad configurados.
GET /security/alerts_v2 y GET /security/incidents con filtro por createdDateTime ge {last_poll}. Auth con certificado (App Registration 250c7cb4).
min_severity_globalDescarta alertas por debajo del umbral mínimo global de config antes de persistir. Reduce ruido de alertas informational no deseadas.
graph_enrich.enrich_alert()Por cada usuario en evidence: displayName, jobTitle, department, manager, grupos (top 10). Por cada dispositivo: hostname, OS, isCompliant, isManaged. Cache TTL 5 minutos para no saturar Graph.
INSERT en security_alerts y/o security_incidents. evidence_json y enrichment_json almacenados como JSON. first_seen_at permite detectar si ya existía la alerta.
Recorre severity_filters ordenados por sort_order. Compara min_severity, service_source, category y title_contains. La primera coincidencia gana — determina si notificar y cómo.
notify_sisalerts: true)sisalerts_client.ingest_security_alert() envía la alerta enriquecida con rule_id y priority del filtro. Canal sisalerts en notification_log previene reenvíos.
create_jira: true)
Alerta: ticket tipo Alertas 365 en proyecto 1 (y opcionalmente proyecto 2).
Incidente: ticket madre tipo Incidente 365 en proyecto 1 (y opcionalmente proyecto 2). Alertas del incidente se enlazan con Relates a su respectivo ticket madre — sin links cruzados entre proyectos.
Contenido del ticket: wiki markup completo con severidad, evidencias, MITRE ATT&CK, contexto enriquecido de usuarios y dispositivos.
# notifier.py — flujo simplificado de process_new_alert() def process_new_alert(alert, severity_filter, cfg): # 1. Notificar SISAlerts si procede if severity_filter['notify_sisalerts'] and not already_notified(alert_id, 'sisalerts'): sisalerts_client.ingest_security_alert(alert, severity_filter) log_notification(alert_id, 'sisalerts') # 2. Crear ticket Jira proyecto 1 if severity_filter['create_jira'] and not already_notified(alert_id, 'jira_create'): key1 = jira_client.create_ticket(alert, severity_filter, project='key_1') log_notification(alert_id, 'jira_create', detail=key1) # 3. Crear ticket Jira proyecto 2 (si configurado) if severity_filter.get('jira_project_key_2') and not already_notified(alert_id, 'jira_create_2'): key2 = jira_client.create_ticket(alert, severity_filter, project='key_2') log_notification(alert_id, 'jira_create_2', detail=key2)
Los filtros de severidad son la pieza central de configuración del flujo Security API. Determinan qué alertas notificar, con qué prioridad y a qué proyectos Jira.
| Campo | Tipo | Descripción |
|---|---|---|
name | string | Nombre descriptivo del filtro (ej: “Defender High”) |
min_severity | enum | Severidad mínima para que aplique: high / medium / low / informational |
service_source | string (opt.) | Filtro por origen del servicio (ej: microsoftDefenderForEndpoint). Vacío = cualquiera. |
category | string (opt.) | Subcadena de la categoría de la alerta (case-insensitive contains). Vacío = cualquiera. |
title_contains | string (opt.) | Subcadena del título de la alerta (case-insensitive). Vacío = cualquiera. |
notify_sisalerts | bool | Si se debe enviar a SISAlerts cuando coincide este filtro |
sisalerts_rule_id | string | Rule ID en SISAlerts (determina canales Teams/Telegram) |
sisalerts_priority | enum | Prioridad para SISAlerts: critical / high / normal / low |
create_jira | bool | Si se debe crear ticket Jira |
jira_project_key | string | Proyecto Jira principal (ej: MESA) |
jira_issue_type | string | Tipo de issue en proyecto 1 (ej: Alertas 365) |
jira_labels | string | Labels separadas por coma para proyecto 1 |
jira_project_key_2 | string (opt.) | Segundo proyecto Jira. Vacío = sin segundo ticket |
jira_issue_type_2 | string (opt.) | Tipo de issue en proyecto 2 |
jira_labels_2 | string (opt.) | Labels para proyecto 2 |
enabled | bool | Toggle on/off sin eliminar el filtro |
sort_order | int | Orden de evaluación. Reordenable con drag&drop en la UI. |
sort_order. En cuanto una alerta satisface todos los criterios de un filtro (min_severity, service_source si está definido, category si está definido, title_contains si está definido), ese filtro es el que aplica y el resto no se evalúa. Los filtros deshabilitados se saltan.
Se crean dos tickets independientes: uno en jira_project_key (ej: MESA-1234) y otro en jira_project_key_2 (ej: SOS-567). Cada canal de idempotencia es independiente: jira_create y jira_create_2.
Ticket madre en proyecto 1 y ticket madre en proyecto 2. Las alertas hijas se enlazan (Relates) al ticket madre de su mismo proyecto. No hay links cruzados entre proyectos. Canales: jira_link:{incident_key_1} y jira_link_2:{incident_key_2}.
| Clave config | Descripción |
|---|---|
default_jira_project | Proyecto 1 por defecto (si el filtro de severidad no especifica proyecto) |
default_jira_issue_type | Tipo de issue para alertas en proyecto 1 (ej: Alertas 365) |
default_jira_incident_type | Tipo de issue para incidentes en proyecto 1 (ej: Incidente 365). Campo separado del tipo de alerta. |
default_jira_labels | Labels por defecto para proyecto 1 |
default_jira_project_2 | Proyecto 2 por defecto |
default_jira_issue_type_2 | Tipo de issue para alertas en proyecto 2 |
default_jira_incident_type_2 | Tipo de issue para incidentes en proyecto 2 |
recommended_actions, técnicas MITRE ATT&CK, contexto enriquecido de usuarios (con manager y grupos), contexto de dispositivos y evidencia técnica (procesos, ficheros, conexiones de red, claves de registro, URLs).
El scheduler dispara automáticamente un informe HTML de postura M365 los días 1 y 15 de cada mes. También se puede generar bajo demanda desde la UI de administración.
Consulta auditLogs/signIns filtrando códigos de error 50053 (cuenta bloqueada), 50126 (credenciales inválidas), 50055 (contraseña caducada) y 90095 (consentimiento de admin requerido).
Obtiene identityProtection/riskDetections del período y los agrupa por tipo de detección para mostrar la distribución de amenazas de identidad.
Agrega las alertas del período por categoría, servicio y severidad. Usa security/alerts_v2 con chunks diarios y sleep(3) entre días para evitar throttling.
Lista identityProtection/riskyUsers activos. Auto-dismiss de usuarios en riesgo stale (sin actividad reciente) si auto_dismiss: true en config.
Distribución geográfica de sign-ins bloqueados renderizada con Chart.js + topojson. Descargados al primer arranque del servicio en assets/.
El informe HTML se adjunta al ticket Jira creado en default_jira_project. Todos los tickets de incidentes del período se enlazan al ticket del informe con Relates.
| Modo | Ventana temporal | Descripción |
|---|---|---|
| Programado (días 1 y 15) | since_dt = última ejecución exitosa (fallback: 60 días) |
El scheduler verifica cada ciclo si es día 1 o 15 y si ya se ejecutó hoy. Registra en protection_report_runs. |
| Bajo demanda (“Generar informe ahora”) | Configurable: days_back desde la UI |
Botón en admin_settings.html. Estado en tiempo real via polling cada 5s. |
protection_report_runs| Campo | Descripción |
|---|---|
id | PK auto-increment |
run_at | Timestamp de ejecución |
on_demand | 0 = programado, 1 = manual |
since_dt | Fecha de inicio del período analizado |
period | Descripción del período (ej: “2026-04-15 → 2026-05-01”) |
jira_key | Clave del ticket Jira creado (ej: MESA-890) |
status | ok / error / running |
error | Mensaje de error si status=error |
| Endpoint | Permiso requerido | Uso |
|---|---|---|
auditLogs/signIns | AuditLog.Read.All | Sign-ins bloqueados, chunks diarios |
identityProtection/riskDetections | IdentityRiskEvent.Read.All | Risk events por tipo |
security/alerts_v2 | SecurityAlert.Read.All | Alertas del período, chunks diarios + sleep(3) |
identityProtection/riskyUsers | IdentityRiskyUser.ReadWrite.All | Usuarios en riesgo + auto-dismiss stale |
SQLite en modo WAL. Auto-migraciones al arrancar (columnas nuevas se añaden sin recrear tablas). Bootstrap admin configurado en app-config.json.
| Tabla | Flujo | Columnas clave |
|---|---|---|
security_alerts |
Security API | graph_alert_id, title, severity, status, category, service_source, description, recommended_actions, mitre_techniques, actor_display_name, first_activity_datetime, last_activity_datetime, evidence_json, enrichment_json, jira_ticket_key, jira_ticket_key_2, sisalerts_id, graph_incident_id, created_at_graph, first_seen_at |
security_incidents |
Security API | graph_incident_id, display_name, severity, status, classification, incident_web_url, jira_ticket_key, jira_ticket_key_2, created_at_graph, first_seen_at |
severity_filters |
Security API | name, min_severity, service_source, category, title_contains, notify_sisalerts, sisalerts_rule_id, sisalerts_priority, create_jira, jira_project_key, jira_issue_type, jira_labels, jira_project_key_2, jira_issue_type_2, jira_labels_2, enabled, sort_order |
notification_log |
Security API | entity_type, entity_graph_id, channel, detail, status, created_at |
protection_report_runs |
Protection Report | run_at, on_demand, since_dt, period, jira_key, status, error |
users |
SSO | upn, is_admin, last_seen |
notification_log:
sisalerts — jira_create — jira_create_2 — jira_link:{incident_jira_key} — jira_link_2:{incident_jira_key_2}.
Cada canal se registra con status ok o error. Solo se intenta una notificación si no existe ya un registro ok para ese canal + entidad.
Backend Python modular con servicios desacoplados. Frontend Jinja2 + Vanilla JS, sin frameworks ni build step. Entry point único que arranca Flask + scheduler.
security-monitor/ config/ app-config.json — Config local (gitignored) app-config.example.json — Plantilla documentada con todos los campos db/ database.py — SQLite WAL, auto-migraciones, bootstrap admin schema.sql — Tablas base (9 tablas) services/ graph_security.py — fetch_alerts(), fetch_incidents(), fetch_alert_by_id() graph_auth.py — MSAL singleton — cert store Windows o fichero PFX/PEM graph_enrich.py — enrich_alert() — usuario + dispositivo, cache TTL 5min graph_protection.py — Fetchers para informe de protección (signIns, risks, alerts, risky_users) jira_client.py — create_ticket(), add_comment(), link_issues(), add_attachment() notifier.py — process_new_alert(), process_new_incident(), update_incident_ticket() protection_report.py — run_protection_report(), render_html(), load_assets() sisalerts_client.py — ingest_security_alert() — POST a SISAlerts /api/ingest assets/ — Chart.js + topojson (gitignored, descargados al primer arranque) webapp/ app.py — Flask app, blueprints, init DB auth.py — SSO App Proxy (X-MS-CLIENT-PRINCIPAL-NAME) routes/ admin.py — CRUD severity_filters, settings, test-jira-pipeline, ingest-alert api.py — /api/test-jira, /api/poll-security-now, /api/status dashboard.py — Stats Security API (alertas, incidentes) templates/ base.html — Layout con navbar, theme toggle dashboard.html — Dashboard: incidentes + alertas recientes, poll manual admin_severity.html — CRUD filtros severidad + drag&drop + badge segundo proyecto admin_settings.html — Config + Jira defaults + informe protección on-demand scheduler.py — Loop polling Security API + protection report scheduler run_webapp.py — Entry point: Flask + scheduler en background thread security-monitor-service.xml — Descriptor WinSW deploy.ps1 — git pull + pip install + restart servicio
UI completa para gestionar filtros de severidad, probar la integración Jira, importar alertas manualmente y generar el informe de protección bajo demanda.
| Sección Admin | Funcionalidades |
|---|---|
| Filtros de severidad | CRUD completo + drag&drop reorder + toggle on/off. Formulario inline para crear. Modal para editar. Badge visual del segundo proyecto en la tabla. |
| Ajustes Jira | Configuración de default_jira_project, default_jira_issue_type (alertas), default_jira_incident_type (incidentes) para proyecto 1 y 2. Botón “Test pipeline” que crea tickets [TEST] reales en todos los proyectos configurados (incidente + alerta enlazados). |
| Importar alerta manualmente | Campo para introducir un Graph alert ID. Re-importa la alerta forzando re-notificación (limpia el notification_log de esa entidad). |
| Test SISAlerts | Envía una alerta sintética con rule_id configurable. Verifica conectividad sin necesitar alerta real. |
| Informe de protección on-demand | Botón “Generar informe ahora” con campo days_back. Estado en tiempo real vía polling cada 5 segundos (running → ok / error). |
| Dashboard Security | Stats últimas 24h / 7 días. Tabla de alertas recientes con badge de severidad y links directos a Jira y al portal M365. |
Badges de severidad y estado usados en el dashboard.
app-config.jsonTodos los parámetros de configuración del sistema, organizados por categoría.
| Clave | Tipo | Descripción |
|---|---|---|
security_polling_enabled | bool | Activar/desactivar el polling de Graph Security API |
security_polling_interval_seconds | int | Intervalo de polling (defecto: 120) |
min_severity_global | string | Severidad mínima global para preprocesar alertas: high / medium / low / informational |
enrich_alerts | bool | Activar enriquecimiento con Graph User y Device |
security_retention_days | int | Días de retención de alertas e incidentes en BD |
| Clave | Descripción |
|---|---|
jira.base_url | URL base de Jira Cloud (ej: https://idealista.atlassian.net) |
jira.user_email | Email del usuario Jira para autenticación básica |
jira.api_token | API token de Jira (Atlassian API token) |
default_jira_project | Proyecto 1 por defecto (ej: MESA) |
default_jira_issue_type | Tipo issue para alertas en proyecto 1 (ej: Alertas 365) |
default_jira_incident_type | Tipo issue para incidentes en proyecto 1 (ej: Incidente 365) |
default_jira_labels | Labels por defecto proyecto 1 (lista) |
default_jira_project_2 | Proyecto 2 por defecto (ej: SOS) |
default_jira_issue_type_2 | Tipo issue para alertas en proyecto 2 |
default_jira_incident_type_2 | Tipo issue para incidentes en proyecto 2 |
| Clave | Descripción |
|---|---|
graph.tenant_id | Tenant ID de Entra ID (d78b7929-c2a3-4897-ae9a-7d8f8dc1a1cf) |
graph.client_id | Client ID del App Registration (250c7cb4-...) |
graph.auth_mode | Modo de auth: certificate |
graph.certificate_thumbprint | Thumbprint del certificado en el Windows Cert Store |
| Clave | Descripción |
|---|---|
protection_report.enabled | Activar generación automática los días 1 y 15 |
protection_report.jira_project_key | Proyecto Jira donde adjuntar el informe (legacy fallback; usa default_jira_project si no se especifica) |
protection_report.auto_dismiss | Auto-dismiss de risky users stale al generar el informe |
| Clave | Descripción |
|---|---|
port | Puerto de la webapp (8083) |
bootstrap_admin | UPN del admin inicial (se crea en primer arranque) |
sisalerts.url | URL base de SISAlerts (ej: http://sisalerts-server:8082) |
sisalerts.api_key | API key de SISAlerts para /api/ingest |
sso.dev_bypass_upn | UPN para bypass SSO en desarrollo local |
// app-config.example.json — estructura completa { "port": 8083, "bootstrap_admin": "[email protected]", "security_polling_enabled": false, "security_polling_interval_seconds": 120, "min_severity_global": "medium", "enrich_alerts": true, "security_retention_days": 90, "graph": { "tenant_id": "d78b7929-c2a3-4897-ae9a-7d8f8dc1a1cf", "client_id": "250c7cb4-...", "auth_mode": "certificate", "certificate_thumbprint": "..." }, "sisalerts": { "url": "http://sisalerts-server:8082", "api_key": "..." }, "default_jira_project": "MESA", "default_jira_issue_type": "Alertas 365", "default_jira_incident_type": "Incidente 365", "default_jira_project_2": "SOS", "default_jira_issue_type_2": "Alertas 365", "default_jira_incident_type_2": "Incidente 365", "jira": { "base_url": "https://idealista.atlassian.net", "user_email": "...", "api_token": "..." }, "protection_report": { "enabled": true, "auto_dismiss": false }, "sso": { "dev_bypass_upn": "" } }
250c7cb4)App Registration en Entra ID con autenticación por certificado (thumbprint en config). Permisos de tipo Application (sin usuario interactivo).
| Permiso | Tipo | Uso |
|---|---|---|
SecurityAlert.Read.All | Application | Leer alertas Defender vía /security/alerts_v2 |
SecurityIncident.Read.All | Application | Leer incidentes vía /security/incidents |
User.Read.All | Application | Enriquecimiento: perfil, manager de usuarios en la evidencia |
Device.Read.All | Application | Enriquecimiento: hostname, OS, compliance de dispositivos |
GroupMember.Read.All | Application | Enriquecimiento: grupos del usuario (top 10) |
AuditLog.Read.All | Application | Informe de protección: sign-ins bloqueados |
IdentityRiskEvent.Read.All | Application | Informe de protección: risk detections |
IdentityRiskyUser.ReadWrite.All | Application | Informe de protección: listar y hacer dismiss de risky users |
WinSW como servicio Windows. Entra ID App Proxy para acceso externo con SSO. Deploy automático vía deploy.ps1.
sso.dev_bypass_upn en configsecurity-monitor-service.xml (WinSW)logs/deploy.ps1: git pull + pip installLos principales retos de implementación del sistema, con la solución adoptada en cada caso.
Si el servicio se reinicia a mitad de un ciclo de polling, puede intentar re-notificar alertas ya enviadas.
Solución: tabla notification_log con canales granulares (sisalerts, jira_create, jira_create_2, jira_link:{key}, jira_link_2:{key}). Antes de cualquier notificación se consulta si ya existe un registro ok para esa entidad+canal. Las operaciones son idempotentes por diseño.
Con dual project activo, una alerta tiene un ticket en MESA y otro en SOS, y su incidente padre también tiene tickets en MESA y SOS. Los links Relates deben unir MESA-alerta con MESA-incidente y SOS-alerta con SOS-incidente, nunca cruzados.
Solución: canales de idempotencia separados jira_link:{incident_key_1} vs jira_link_2:{incident_key_2}. El notifier resuelve qué clave de incidente usar según el proyecto del ticket de alerta.
El informe analiza ventanas de 15-60 días con queries diarias para sign-ins y alertas. Hacer todas las peticiones seguidas agota rápidamente el quota de Graph.
Solución: chunks diarios con sleep(3) entre peticiones en graph_protection.py. El cache TTL de 5 minutos en graph_enrich.py reduce las peticiones de enriquecimiento repetidas para los mismos usuarios/dispositivos.
Alertas e incidentes son entidades diferentes en Defender y merecen tipos de issue distintos en Jira (Alertas 365 vs Incidente 365). Sin embargo, los filtros de severidad solo tenían un campo de tipo issue.
Solución: se añadió default_jira_incident_type (y _2) en config global, separado de default_jira_issue_type. El notifier selecciona el tipo correcto según si la entidad es alerta o incidente.
La generación del informe puede tardar varios minutos (queries de 60 días + render + subida a Jira). La UI necesita mostrar progreso sin websockets.
Solución: la generación se lanza en un thread y escribe el estado en protection_report_runs (running → ok/error). La UI hace polling GET cada 5 segundos para actualizar el badge de estado sin recargar la página.
El enriquecimiento de usuario requiere hasta 3 llamadas Graph por usuario (profile, manager, groups). En un ciclo con muchas alertas del mismo usuario, esto multiplica las peticiones.
Solución: cache en memoria con TTL de 5 minutos en graph_enrich.py, keyed por UPN/device_id. En el ciclo de 120s, si el mismo usuario aparece en varias alertas consecutivas se sirve del cache. El TTL corto asegura que cambios organizativos (manager, grupos) se reflejan en el día.
| Capa | Tecnología | Uso |
|---|---|---|
| Backend runtime | Python 3 |
Webapp Flask + scheduler en background thread. Un único proceso. |
| Web framework | Flask + Waitress |
HTTP server WSGI en producción. Blueprints: admin, api, dashboard. |
| Base de datos | SQLite (WAL mode) |
9 tablas. WAL permite lecturas concurrentes durante escrituras del scheduler. |
| Microsoft Graph | requests + MSAL cert |
Security API, Mail, AuditLogs, IdentityProtection. Auth con certificado del Windows Cert Store. |
| Integración Jira | requests (Jira REST v3) |
Crear tickets, añadir comentarios, enlazar issues, adjuntar ficheros. Dual project. |
| Frontend | Jinja2 + Vanilla JS | Templates server-side. JS sin frameworks ni build step. Drag&drop HTML5 API. |
| Charts (informe) | Chart.js + topojson | Choropleth map de sign-ins + bar charts. Descargados al primer arranque. |
| Auth | Entra ID App Proxy | SSO vía cabecera X-MS-CLIENT-PRINCIPAL-NAME. Bypass configurable para dev. |
| Servicio Windows | WinSW | Descriptor XML para instalar como servicio Windows en producción. |
| Destino alertas | SISAlerts /api/ingest |
ingest_security_alert() para Graph Security flow. |