main
py 166 lines 6.32 KB
Raw
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}")