@cryptotaxi247 / CoPilot / commits / 375c92f6

feat(integrations): forward alerts to SOCFortress MDR per customer (#894)

* feat(integrations): forward alerts to SOCFortress MDR per customer Add a "SOCFortress MDR" customer integration that forwards newly-created alerts to the MDR server's POST /api/v1/alerts/copilot endpoint when the integration is deployed for that customer. - Catalog: seed "SOCFortress MDR" + COLLECTOR_UUID auth key (db_populate.py) - Provision module (app/integrations/socfortress_mdr/) + router, mirroring the existing integration provision pattern; deploy validates the COLLECTOR_UUID and sets customer_integrations.deployed=True - Forwarder (mdr_forwarder.py) hooked into handle_customer_notifications; resolves the collector UUID per-customer from the integration auth key (MDR_COLLECTOR_UUID env is a single-tenant fallback). Best-effort: never breaks alert creation; skips alerts without index_name/index_id - Settings + .env.example: MDR_ENABLED / MDR_SERVER_URL / MDR_COLLECTOR_UUID - Frontend: wire the Deploy button + provision API call for the new integration Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(integrations): update MDR server URL default and improve logging format --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

taylor_socfortress committed May 29, 2026 at 16:11 UTC 375c92f69b46025c6e8bf71c5a8f4f5b2848ab93
13 files changed +442 -1
.env.example
+9
@@ -160,3 +160,12 @@ MCP_VELOCIRAPTOR_AUTH_TOKEN=velociraptor-token
160 MCP_VELOCIRAPTOR_SERVER_ENABLED=true
161 MCP_VELOCIRAPTOR_HOST=0.0.0.0
162 MCP_VELOCIRAPTOR_PORT=8001
163 +
164 +# SOCFortress MDR Forwarding
165 +# When enabled, alerts for customers that have the "SOCFortress MDR" integration
166 +# deployed are forwarded to the MDR server (POST /api/v1/alerts/copilot). The
167 +# per-customer collector UUID is entered in the integration UI; MDR_COLLECTOR_UUID
168 +# below is only a single-tenant fallback.
169 +MDR_ENABLED=false
170 +MDR_SERVER_URL=https://mdr-server.socfortress.co:8443
171 +MDR_COLLECTOR_UUID=
backend/app/db/db_populate.py
+2
@@ -346,6 +346,7 @@ def get_available_integrations_list():
346 ("BitDefender", "Integrate BitDefender with SOCFortress."),
347 ("CATO", "Integrate CATO NETWORKS with SOCFortress."),
348 ("DefenderForEndpoint", "Integrate DefenderForEndpoint with SOCFortress."),
349 + ("SOCFortress MDR", "Forward alerts to the SOCFortress MDR server for this customer."),
350 # ... Add more available integrations as needed ...
351 ]
352
@@ -488,6 +489,7 @@ async def get_available_integrations_auth_keys_list(session: AsyncSession):
489 ("DefenderForEndpoint", "CLIENT_ID"),
490 ("DefenderForEndpoint", "CLIENT_SECRET"),
491 ("DefenderForEndpoint", "SYSLOG_PORT"),
492 + ("SOCFortress MDR", "COLLECTOR_UUID"),
493 # ... Add more available integrations auth keys as needed ...
494 ]
495 logger.info("Getting available integrations auth keys.")
backend/app/incidents/services/incident_alert.py
+11
@@ -655,6 +655,17 @@ async def handle_customer_notifications(
655 ),
656 )
657
658 + # Forward alerts (not cases) to SOCFortress MDR when the customer has the
659 + # integration deployed. Best-effort: never breaks alert creation.
660 + if type == "alert":
661 + from app.incidents.services.mdr_forwarder import forward_alert_to_mdr
662 +
663 + await forward_alert_to_mdr(
664 + customer_code=customer_code,
665 + alert_payload=alert_payload,
666 + session=session,
667 + )
668 +
669
670 # ! OLD FUNCTION ! #
671 # async def create_alert_full(
backend/app/incidents/services/mdr_forwarder.py new
+166
@@ -0,0 +1,166 @@
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}")
backend/app/integrations/markdown/socfortress_mdr.md new
+29
@@ -0,0 +1,29 @@
1 +# SOCFortress MDR
2 +
3 +Forward this customer's alerts to the SOCFortress MDR server.
4 +
5 +When deployed, every alert created in CoPilot for this customer is sent to the
6 +MDR server (`POST /api/v1/alerts/copilot`). The MDR server then tasks the
7 +customer's collector to fetch the authoritative document from the Wazuh Indexer
8 +and runs its analysis. Alert status changes made in MDR are pushed back to
9 +CoPilot automatically.
10 +
11 +## Requirements
12 +
13 +- The MDR server must be reachable from CoPilot. Set `MDR_ENABLED=true` and
14 + `MDR_SERVER_URL` (e.g. `https://mdr-server.socfortress.co:8443`) in CoPilot's
15 + `.env`.
16 +- The customer must have a registered collector on the MDR side.
17 +
18 +## Auth keys
19 +
20 +| Key | Description |
21 +| --- | --- |
22 +| `COLLECTOR_UUID` | The MDR collector UUID assigned to this customer. Used to authenticate the alert hand-off to the MDR server. |
23 +
24 +## Deploy
25 +
26 +After adding the integration with the customer's `COLLECTOR_UUID`, click
27 +**Deploy**. Provisioning validates the collector UUID and marks the integration
28 +active — no Graylog/Grafana resources are created (the MDR server pulls alerts
29 +on demand via the collector).
backend/app/integrations/socfortress_mdr/routes/provision.py new
+96
@@ -0,0 +1,96 @@
1 +from fastapi import APIRouter
2 +from fastapi import Depends
3 +from fastapi import HTTPException
4 +from fastapi import Security
5 +from sqlalchemy.ext.asyncio import AsyncSession
6 +
7 +from app.auth.utils import AuthHandler
8 +from app.db.db_session import get_db
9 +from app.integrations.routes import find_customer_integration
10 +from app.integrations.routes import get_customer_integrations_by_customer_code
11 +from app.integrations.schema import CustomerIntegrations
12 +from app.integrations.schema import CustomerIntegrationsResponse
13 +from app.integrations.socfortress_mdr.schema.provision import (
14 + ProvisionSOCFortressMDRRequest,
15 +)
16 +from app.integrations.socfortress_mdr.schema.provision import (
17 + ProvisionSOCFortressMDRResponse,
18 +)
19 +from app.integrations.socfortress_mdr.services.provision import INTEGRATION_NAME
20 +from app.integrations.socfortress_mdr.services.provision import (
21 + provision_socfortress_mdr,
22 +)
23 +
24 +integration_socfortress_mdr_router = APIRouter()
25 +
26 +
27 +async def get_customer_integration_response(
28 + customer_code: str,
29 + session: AsyncSession,
30 +) -> CustomerIntegrationsResponse:
31 + """Retrieve the integration settings for a customer (404 if none)."""
32 + customer_integration_response = await get_customer_integrations_by_customer_code(
33 + customer_code,
34 + session,
35 + )
36 + if customer_integration_response.available_integrations == []:
37 + raise HTTPException(
38 + status_code=404,
39 + detail="Customer integration settings not found.",
40 + )
41 + return customer_integration_response
42 +
43 +
44 +def extract_collector_uuid(customer_integration: CustomerIntegrations) -> str:
45 + """Pull the COLLECTOR_UUID auth-key value off the SOCFortress MDR subscription."""
46 + for subscription in customer_integration.integration_subscriptions:
47 + if subscription.integration_service.service_name == INTEGRATION_NAME:
48 + for auth_key in subscription.integration_auth_keys:
49 + if auth_key.auth_key_name == "COLLECTOR_UUID":
50 + return auth_key.auth_value
51 + raise HTTPException(
52 + status_code=404,
53 + detail=(
54 + "COLLECTOR_UUID auth key not found for the SOCFortress MDR integration. "
55 + "Add the integration with a COLLECTOR_UUID before deploying."
56 + ),
57 + )
58 +
59 +
60 +@integration_socfortress_mdr_router.post(
61 + "/provision",
62 + response_model=ProvisionSOCFortressMDRResponse,
63 + description="Provision SOCFortress MDR integration for a customer.",
64 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
65 +)
66 +async def provision_socfortress_mdr_route(
67 + provision_request: ProvisionSOCFortressMDRRequest,
68 + session: AsyncSession = Depends(get_db),
69 +) -> ProvisionSOCFortressMDRResponse:
70 + """
71 + Provision SOCFortress MDR for a customer: validate the COLLECTOR_UUID auth key
72 + is present, then mark the integration deployed (enabling alert forwarding).
73 + """
74 + customer_integration_response = await get_customer_integration_response(
75 + provision_request.customer_code,
76 + session,
77 + )
78 +
79 + customer_integration = await find_customer_integration(
80 + provision_request.customer_code,
81 + provision_request.integration_name,
82 + customer_integration_response,
83 + )
84 +
85 + collector_uuid = extract_collector_uuid(customer_integration)
86 + if not collector_uuid or not collector_uuid.strip():
87 + raise HTTPException(
88 + status_code=400,
89 + detail="COLLECTOR_UUID is empty. Provide the MDR collector UUID before deploying.",
90 + )
91 +
92 + return await provision_socfortress_mdr(
93 + customer_code=provision_request.customer_code,
94 + collector_uuid=collector_uuid,
95 + session=session,
96 + )
backend/app/integrations/socfortress_mdr/schema/provision.py new
+20
@@ -0,0 +1,20 @@
1 +from pydantic import BaseModel
2 +from pydantic import Field
3 +
4 +
5 +class ProvisionSOCFortressMDRRequest(BaseModel):
6 + customer_code: str = Field(
7 + ...,
8 + description="The customer code.",
9 + examples=["00001"],
10 + )
11 + integration_name: str = Field(
12 + "SOCFortress MDR",
13 + description="The integration name.",
14 + examples=["SOCFortress MDR"],
15 + )
16 +
17 +
18 +class ProvisionSOCFortressMDRResponse(BaseModel):
19 + success: bool
20 + message: str
backend/app/integrations/socfortress_mdr/services/provision.py new
+68
@@ -0,0 +1,68 @@
1 +from loguru import logger
2 +from sqlalchemy import and_
3 +from sqlalchemy import update
4 +from sqlalchemy.ext.asyncio import AsyncSession
5 +
6 +from app.integrations.models.customer_integration_settings import CustomerIntegrations
7 +from app.integrations.socfortress_mdr.schema.provision import (
8 + ProvisionSOCFortressMDRResponse,
9 +)
10 +
11 +INTEGRATION_NAME = "SOCFortress MDR"
12 +
13 +
14 +async def update_customer_integration_table(
15 + customer_code: str,
16 + session: AsyncSession,
17 +) -> None:
18 + """
19 + Set `deployed = True` on the customer's "SOCFortress MDR" integration row.
20 +
21 + Args:
22 + customer_code (str): The customer code.
23 + session (AsyncSession): The async database session.
24 + """
25 + logger.info(f"Marking SOCFortress MDR integration deployed for customer {customer_code}")
26 + await session.execute(
27 + update(CustomerIntegrations)
28 + .where(
29 + and_(
30 + CustomerIntegrations.customer_code == customer_code,
31 + CustomerIntegrations.integration_service_name == INTEGRATION_NAME,
32 + ),
33 + )
34 + .values(deployed=True),
35 + )
36 + await session.commit()
37 +
38 +
39 +async def provision_socfortress_mdr(
40 + customer_code: str,
41 + collector_uuid: str,
42 + session: AsyncSession,
43 +) -> ProvisionSOCFortressMDRResponse:
44 + """
45 + Provision the SOCFortress MDR integration for a customer.
46 +
47 + Unlike most integrations there is no Graylog/Grafana infrastructure to stand
48 + up here — the MDR server and the customer's collector already exist. The MDR
49 + server pulls alerts on demand via the collector. Provisioning therefore just
50 + records the COLLECTOR_UUID (already validated by the route) and marks the
51 + integration deployed so alert forwarding is enabled for this customer.
52 +
53 + Args:
54 + customer_code (str): The customer code.
55 + collector_uuid (str): The MDR collector UUID for this customer.
56 + session (AsyncSession): The async database session.
57 +
58 + Returns:
59 + ProvisionSOCFortressMDRResponse
60 + """
61 + logger.info(
62 + f"Provisioning SOCFortress MDR integration for customer {customer_code} " f"(collector {collector_uuid})",
63 + )
64 + await update_customer_integration_table(customer_code, session)
65 + return ProvisionSOCFortressMDRResponse(
66 + success=True,
67 + message="SOCFortress MDR integration provisioned successfully.",
68 + )
backend/app/routers/socfortress_mdr.py new
+15
@@ -0,0 +1,15 @@
1 +from fastapi import APIRouter
2 +
3 +from app.integrations.socfortress_mdr.routes.provision import (
4 + integration_socfortress_mdr_router,
5 +)
6 +
7 +# Instantiate the APIRouter
8 +router = APIRouter()
9 +
10 +# Include the SOCFortress MDR related routes
11 +router.include_router(
12 + integration_socfortress_mdr_router,
13 + prefix="/socfortress_mdr",
14 + tags=["SOCFortress MDR"],
15 +)
backend/copilot.py
+2
@@ -85,6 +85,7 @@ from app.routers import scheduler
85 from app.routers import scoutsuite
86 from app.routers import shuffle
87 from app.routers import siem
88 +from app.routers import socfortress_mdr
89 from app.routers import stack_provisioning
90 from app.routers import sublime
91 from app.routers import talon
@@ -214,6 +215,7 @@ api_router.include_router(carbonblack.router)
215 api_router.include_router(network_connectors.router)
216 api_router.include_router(crowdstrike.router)
217 api_router.include_router(bitdefender.router)
218 +api_router.include_router(socfortress_mdr.router)
219 api_router.include_router(scoutsuite.router)
220 api_router.include_router(nuclei.router)
221 api_router.include_router(duo.router)
backend/settings.py
+12
@@ -35,3 +35,15 @@ SQLALCHEMY_TRACK_MODIFICATIONS = env.bool(
35 "SQLALCHEMY_TRACK_MODIFICATIONS",
36 default=False,
37 )
38 +
39 +# ---------------------------------------------------------------------------
40 +# SOCFortress MDR forwarding
41 +# ---------------------------------------------------------------------------
42 +# When enabled, newly-created alerts for customers that have the "SOCFortress
43 +# MDR" integration deployed are forwarded to the MDR server's
44 +# POST /api/v1/alerts/copilot endpoint. The MDR server then tasks the collector
45 +# to fetch the authoritative indexer document. One CoPilot stack maps to one
46 +# MDR collector, so the collector UUID and MDR base URL are global env values.
47 +MDR_ENABLED = env.bool("MDR_ENABLED", default=False)
48 +MDR_SERVER_URL = env.str("MDR_SERVER_URL", default="https://mdr-server.socfortress.co")
49 +MDR_COLLECTOR_UUID = env.str("MDR_COLLECTOR_UUID", default="")
frontend/src/api/endpoints/integrations.ts
+6
@@ -132,6 +132,12 @@ export default {
132 customer_code: customerCode,
133 integration_name: integrationName || "DefenderForEndpoint"
134 })
135 + },
136 + socfortressMdrProvision(customerCode: string, integrationName: string) {
137 + return HttpClient.post<FlaskBaseResponse>(`/socfortress_mdr/provision`, {
138 + customer_code: customerCode,
139 + integration_name: integrationName || "SOCFortress MDR"
140 + })
141 }
142 // #endregion
143 }
frontend/src/components/customers/integrations/CustomerIntegrationActions.vue
+6 -1
@@ -58,6 +58,7 @@ const isDarktrace = computed(() => serviceName.value === "Darktrace")
58 const isBitdefender = computed(() => serviceName.value === "BitDefender")
59 const isCato = computed(() => serviceName.value === "CATO")
60 const isDefenderForEndpoint = computed(() => serviceName.value === "DefenderForEndpoint")
61 +const isSOCFortressMdr = computed(() => serviceName.value === "SOCFortress MDR")
62 const isDeployEnabled = computed(
63 () =>
64 (isOffice365.value ||
@@ -67,7 +68,8 @@ const isDeployEnabled = computed(
68 isDarktrace.value ||
69 isBitdefender.value ||
70 isCato.value ||
70 - isDefenderForEndpoint.value) &&
71 + isDefenderForEndpoint.value ||
72 + isSOCFortressMdr.value) &&
73 !integration.deployed
74 )
75
@@ -106,6 +108,9 @@ function provision() {
108 if (isDefenderForEndpoint.value) {
109 apiCall = Api.integrations.defenderForEndpointProvision(customerCode.value, serviceName.value)
110 }
111 + if (isSOCFortressMdr.value) {
112 + apiCall = Api.integrations.socfortressMdrProvision(customerCode.value, serviceName.value)
113 + }
114
115 if (!apiCall) {
116 return