1
+"""
2
+Forward newly-created CoPilot alerts to the SOCFortress MDR server.
3
+
4
+When MDR forwarding is enabled (MDR_ENABLED) and a customer has the
5
+"SOCFortress MDR" integration deployed, this module POSTs the alert's indexer
6
+pointers + CoPilot alert ID to the MDR server:
7
+
8
+ POST {MDR_SERVER_URL}/api/v1/alerts/copilot
9
+ {
10
+ "collector_uuid": <MDR_COLLECTOR_UUID>,
11
+ "index_name": <Wazuh Indexer index name>,
12
+ "index_id": <Wazuh Indexer document id>,
13
+ "copilot_alert_id": <CoPilot alert id>
14
+ }
15
+
16
+The MDR server authenticates the request by the collector UUID (no bearer
17
+token) and then tasks the customer's collector to fetch the authoritative
18
+document from the Wazuh Indexer. Forwarding is best-effort: failures are logged
19
+and never propagate to alert creation.
20
+"""
21
+
22
+import os
23
+from typing import Optional
24
+
25
+import httpx
26
+from loguru import logger
27
+from sqlalchemy.ext.asyncio import AsyncSession
28
+from sqlalchemy.future import select
29
+from sqlalchemy.orm import joinedload
30
+
31
+from app.incidents.schema.incident_alert import CreatedAlertPayload
32
+from app.integrations.models.customer_integration_settings import CustomerIntegrations
33
+from app.integrations.models.customer_integration_settings import (
34
+ IntegrationSubscription,
35
+)
36
+
37
+# Name of the customer integration that gates MDR forwarding. Must match the
38
+# integration_service_name stored in the customer_integrations table and the
39
+# catalog entry seeded in app/db/db_populate.py.
40
+MDR_INTEGRATION_NAME = "SOCFortress MDR"
41
+
42
+_MDR_TIMEOUT_S = 30.0
43
+
44
+
45
+def _mdr_config() -> dict:
46
+ """Read global MDR forwarding config from the environment (see settings.py)."""
47
+ return {
48
+ "enabled": os.getenv("MDR_ENABLED", "False").lower() in ("true", "1", "yes"),
49
+ "server_url": os.getenv("MDR_SERVER_URL", "").rstrip("/"),
50
+ # Per-customer COLLECTOR_UUID (integration auth key) takes precedence; this
51
+ # env value is only a fallback for single-tenant CoPilot deployments.
52
+ "collector_uuid_fallback": os.getenv("MDR_COLLECTOR_UUID", ""),
53
+ }
54
+
55
+
56
+async def get_mdr_collector_uuid(
57
+ customer_code: str,
58
+ session: AsyncSession,
59
+) -> Optional[str]:
60
+ """
61
+ Return the MDR collector UUID for a customer, or None if the customer is not
62
+ an MDR customer.
63
+
64
+ Gating: the customer must have the "SOCFortress MDR" integration with
65
+ deployed=True. The collector UUID comes from that integration's COLLECTOR_UUID
66
+ auth key (per-customer); if absent, falls back to the MDR_COLLECTOR_UUID env
67
+ var (single-tenant convenience). Returns None if neither is available.
68
+ """
69
+ result = await session.execute(
70
+ select(CustomerIntegrations)
71
+ .options(
72
+ joinedload(CustomerIntegrations.integration_subscriptions).joinedload(
73
+ IntegrationSubscription.integration_auth_keys,
74
+ ),
75
+ )
76
+ .where(CustomerIntegrations.customer_code == customer_code)
77
+ .where(CustomerIntegrations.integration_service_name == MDR_INTEGRATION_NAME)
78
+ .where(CustomerIntegrations.deployed == True), # noqa: E712 (SQLModel needs ==)
79
+ )
80
+ integration = result.scalars().unique().first()
81
+ if integration is None:
82
+ return None
83
+
84
+ for subscription in integration.integration_subscriptions:
85
+ for auth_key in subscription.integration_auth_keys:
86
+ if auth_key.auth_key_name == "COLLECTOR_UUID" and auth_key.auth_value:
87
+ return auth_key.auth_value
88
+
89
+ # Deployed but no per-customer COLLECTOR_UUID stored — fall back to env.
90
+ return _mdr_config()["collector_uuid_fallback"] or None
91
+
92
+
93
+async def forward_alert_to_mdr(
94
+ customer_code: str,
95
+ alert_payload: CreatedAlertPayload,
96
+ session: AsyncSession,
97
+) -> None:
98
+ """
99
+ Forward a freshly-created alert to the MDR server (best-effort, never raises).
100
+
101
+ Args:
102
+ customer_code: The customer the alert belongs to.
103
+ alert_payload: The created alert payload (carries alert_id + index pointers).
104
+ session: Async DB session, used to check the customer's integration.
105
+ """
106
+ try:
107
+ config = _mdr_config()
108
+
109
+ if not config["enabled"]:
110
+ return
111
+
112
+ if not config["server_url"]:
113
+ logger.warning(
114
+ "MDR_ENABLED is set but MDR_SERVER_URL is not configured — " "skipping MDR forward",
115
+ )
116
+ return
117
+
118
+ # Need indexer pointers to forward; threshold/aggregation alerts that have
119
+ # no individual document cannot be fetched by the collector.
120
+ if not alert_payload.index_name or not alert_payload.index_id:
121
+ logger.info(
122
+ f"Skipping MDR forward for customer {customer_code}: alert has no "
123
+ f"index_name/index_id (likely a threshold/aggregation alert)",
124
+ )
125
+ return
126
+
127
+ if alert_payload.alert_id is None:
128
+ logger.warning(
129
+ f"Skipping MDR forward for customer {customer_code}: alert_id is not set",
130
+ )
131
+ return
132
+
133
+ # Gate: customer must have the SOCFortress MDR integration deployed. The
134
+ # collector UUID is resolved per-customer (auth key) with env fallback.
135
+ collector_uuid = await get_mdr_collector_uuid(customer_code, session)
136
+ if not collector_uuid:
137
+ # Not an MDR customer (or deployed without a collector UUID) — nothing to do.
138
+ return
139
+
140
+ url = f"{config['server_url']}/api/v1/alerts/copilot"
141
+ body = {
142
+ "collector_uuid": collector_uuid,
143
+ "index_name": alert_payload.index_name,
144
+ "index_id": alert_payload.index_id,
145
+ "copilot_alert_id": alert_payload.alert_id,
146
+ }
147
+
148
+ logger.info(
149
+ f"Forwarding CoPilot alert {alert_payload.alert_id} (customer {customer_code}) " f"to MDR at {url}",
150
+ )
151
+
152
+ async with httpx.AsyncClient(timeout=_MDR_TIMEOUT_S) as client:
153
+ response = await client.post(url, json=body)
154
+
155
+ if response.status_code >= 400:
156
+ logger.error(
157
+ f"MDR forward failed for alert {alert_payload.alert_id} " f"(HTTP {response.status_code}): {response.text[:300]}",
158
+ )
159
+ return
160
+
161
+ logger.info(
162
+ f"MDR forward accepted for alert {alert_payload.alert_id}: " f"{response.text[:200]}",
163
+ )
164
+ except Exception as e:
165
+ # Best-effort: never let MDR forwarding break alert creation.
166
+ logger.error(f"MDR forward errored (non-fatal): {e!r}")