From c0580246858b1faeafecf8738f026f9aaf1e3c5c Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:31:43 +0100 Subject: [PATCH] feat(ee): SIEM --- .../public/locales/en-US/translation.json | 64 ++- apps/client/src/App.tsx | 5 + .../components/settings/settings-sidebar.tsx | 4 +- .../src/ee/audit/lib/audit-event-labels.ts | 32 ++ apps/client/src/ee/audit/pages/audit-logs.tsx | 254 ++++++----- apps/client/src/ee/features.ts | 1 + .../components/delete-destination-modal.tsx | 44 ++ .../components/destination-form-modal.tsx | 300 +++++++++++++ .../components/destination-status-badge.tsx | 15 + .../ee/siem/components/destination-table.tsx | 155 +++++++ .../siem/components/siem-streaming-panel.tsx | 129 ++++++ .../src/ee/siem/lib/destination-form.ts | 179 ++++++++ apps/client/src/ee/siem/queries/siem-query.ts | 115 +++++ .../src/ee/siem/services/siem-service.ts | 46 ++ apps/client/src/ee/siem/types/siem.types.ts | 91 ++++ .../components/notification-item.tsx | 33 +- .../notification/types/notification.types.ts | 5 +- apps/server/package.json | 5 +- apps/server/src/app.module.ts | 4 +- apps/server/src/common/events/audit-events.ts | 17 +- apps/server/src/common/features.ts | 1 + apps/server/src/common/helpers/cache-keys.ts | 1 + .../src/core/auth/services/auth.service.ts | 7 + .../notification/notification.constants.ts | 6 + .../20260902T121326-siem-destinations.ts | 66 +++ apps/server/src/database/types/db.d.ts | 26 ++ .../server/src/database/types/entity.types.ts | 6 + apps/server/src/ee | 2 +- .../environment/environment.service.ts | 4 + .../outbound/outbound-agent.factory.spec.ts | 23 + .../outbound/outbound-agent.factory.ts | 55 +++ .../outbound/outbound-network-policy.spec.ts | 143 ++++++ .../outbound/outbound-network-policy.ts | 110 +++++ .../outbound/outbound-url.guard.spec.ts | 412 ++++++++++++++++++ .../outbound/outbound-url.guard.ts | 231 ++++++++++ .../integrations/outbound/outbound.module.ts | 10 + .../queue/constants/queue.constants.ts | 4 + .../src/integrations/queue/queue.module.ts | 8 + .../integrations/throttle/throttle.module.ts | 2 + .../integrations/throttle/throttler-names.ts | 2 + .../siem-destination-disabled-email.tsx | 41 ++ .../emails/siem-destination-failing-email.tsx | 41 ++ .../siem-destination-recovered-email.tsx | 35 ++ 43 files changed, 2612 insertions(+), 122 deletions(-) create mode 100644 apps/client/src/ee/siem/components/delete-destination-modal.tsx create mode 100644 apps/client/src/ee/siem/components/destination-form-modal.tsx create mode 100644 apps/client/src/ee/siem/components/destination-status-badge.tsx create mode 100644 apps/client/src/ee/siem/components/destination-table.tsx create mode 100644 apps/client/src/ee/siem/components/siem-streaming-panel.tsx create mode 100644 apps/client/src/ee/siem/lib/destination-form.ts create mode 100644 apps/client/src/ee/siem/queries/siem-query.ts create mode 100644 apps/client/src/ee/siem/services/siem-service.ts create mode 100644 apps/client/src/ee/siem/types/siem.types.ts create mode 100644 apps/server/src/database/migrations/20260902T121326-siem-destinations.ts create mode 100644 apps/server/src/integrations/outbound/outbound-agent.factory.spec.ts create mode 100644 apps/server/src/integrations/outbound/outbound-agent.factory.ts create mode 100644 apps/server/src/integrations/outbound/outbound-network-policy.spec.ts create mode 100644 apps/server/src/integrations/outbound/outbound-network-policy.ts create mode 100644 apps/server/src/integrations/outbound/outbound-url.guard.spec.ts create mode 100644 apps/server/src/integrations/outbound/outbound-url.guard.ts create mode 100644 apps/server/src/integrations/outbound/outbound.module.ts create mode 100644 apps/server/src/integrations/transactional/emails/siem-destination-disabled-email.tsx create mode 100644 apps/server/src/integrations/transactional/emails/siem-destination-failing-email.tsx create mode 100644 apps/server/src/integrations/transactional/emails/siem-destination-recovered-email.tsx diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 3a2736f4b..ef7db5d20 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -793,6 +793,10 @@ "Removed page restriction": "Removed page restriction", "Added page permission": "Added page permission", "Removed page permission": "Removed page permission", + "Changed page permission": "Changed page permission", + "Requested password reset": "Requested password reset", + "Created template": "Created template", + "Deleted template": "Deleted template", "day": "day", "days": "days", "week": "week", @@ -880,6 +884,9 @@ "{{name}} returned a page for revision": "{{name}} returned a page for revision", "Page verification expires soon": "Page verification expires soon", "Page verification has expired": "Page verification has expired", + "SIEM destination {{name}} is failing": "SIEM destination {{name}} is failing", + "SIEM destination {{name}} was disabled after 24 hours of failures": "SIEM destination {{name}} was disabled after 24 hours of failures", + "SIEM destination {{name}} recovered": "SIEM destination {{name}} recovered", "Verifying your email": "Verifying your email", "Please wait...": "Please wait...", "Verification failed. The link may have expired.": "Verification failed. The link may have expired.", @@ -1337,5 +1344,60 @@ "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.", "Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode", "Title only": "Title only", - "you": "you" + "you": "you", + "Actions": "Actions", + "Add destination": "Add destination", + "Are you sure you want to delete the destination": "Are you sure you want to delete the destination", + "Audit logs": "Audit logs", + "Audit logs & SIEM": "Audit logs & SIEM", + "Auth header name": "Auth header name", + "Auth header prefix": "Auth header prefix", + "Body format": "Body format", + "Created SIEM destination": "Created SIEM destination", + "Datadog site": "Datadog site", + "Defaults to this instance's hostname": "Defaults to this instance's hostname", + "Delete destination": "Delete destination", + "Deleted SIEM destination": "Deleted SIEM destination", + "Destination created": "Destination created", + "Destination deleted": "Destination deleted", + "Destination updated": "Destination updated", + "Disabled": "Disabled", + "Edit destination": "Edit destination", + "Endpoint URL": "Endpoint URL", + "Failing": "Failing", + "Failing since {{time}}": "Failing since {{time}}", + "HEC token": "HEC token", + "HEC URL": "HEC URL", + "Healthy": "Healthy", + "Hide advanced options": "Hide advanced options", + "Host": "Host", + "Index": "Index", + "Insecure: connections can be intercepted.": "Insecure: connections can be intercepted.", + "JSON array": "JSON array", + "Last delivered": "Last delivered", + "Last error": "Last error", + "Leave empty to use the token's default index": "Leave empty to use the token's default index", + "Maximum of {{limit}} destinations reached": "Maximum of {{limit}} destinations reached", + "Could not load SIEM destinations: {{message}}": "Could not load SIEM destinations: {{message}}", + "No destinations yet": "No destinations yet", + "Preset": "Preset", + "Retry now": "Retry now", + "Retry scheduled": "Retry scheduled", + "Send test event": "Send test event", + "Sent in the auth header below. Leave empty if your receiver does not need one.": "Sent in the auth header below. Leave empty if your receiver does not need one.", + "Service": "Service", + "Show advanced options": "Show advanced options", + "SIEM": "SIEM", + "SIEM streaming": "SIEM streaming", + "SIEM streaming requires an Enterprise license.": "SIEM streaming requires an Enterprise license.", + "Source": "Source", + "Sourcetype": "Sourcetype", + "Tags": "Tags", + "Test connection": "Test connection", + "Test event delivered successfully.": "Test event delivered successfully.", + "Test the connection before saving.": "Test the connection before saving.", + "Test event delivered to {{name}}": "Test event delivered to {{name}}", + "Updated SIEM destination": "Updated SIEM destination", + "Verify TLS certificate": "Verify TLS certificate", + "e.g. Splunk prod": "e.g. Splunk prod" } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 4207c3006..1f7967c2c 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -166,6 +166,11 @@ export default function App() { } /> } /> } /> + } /> + } + /> } /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 542cad910..1a1aec857 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -118,7 +118,7 @@ const groupedData: DataGroup[] = [ role: "admin", }, { - label: "Audit log", + label: "Audit logs & SIEM", icon: IconHistory, path: "/settings/audit", feature: Feature.AUDIT_LOGS, @@ -219,7 +219,7 @@ export default function SettingsSidebar() { case "API management": prefetchHandler = prefetchApiKeyManagement; break; - case "Audit log": + case "Audit logs & SIEM": prefetchHandler = prefetchAuditLogs; break; case "Verified pages": diff --git a/apps/client/src/ee/audit/lib/audit-event-labels.ts b/apps/client/src/ee/audit/lib/audit-event-labels.ts index 7fc55b3d0..2f22514f0 100644 --- a/apps/client/src/ee/audit/lib/audit-event-labels.ts +++ b/apps/client/src/ee/audit/lib/audit-event-labels.ts @@ -22,6 +22,7 @@ export const auditEventLabels: Record = { "user.role_changed": "Changed user role", "user.password_changed": "Changed password", "user.password_reset": "Reset password", + "user.password_reset_requested": "Requested password reset", "user.updated": "Updated user", "user.deactivated": "Deactivated user", "user.activated": "Activated user", @@ -62,6 +63,7 @@ export const auditEventLabels: Record = { "page.restriction_removed": "Removed page restriction", "page.permission_added": "Added page permission", "page.permission_removed": "Removed page permission", + "page.permission_role_changed": "Changed page permission", "page.verification_created": "Created page verification", "page.verification_updated": "Updated page verification", "page.verification_removed": "Removed page verification", @@ -79,6 +81,13 @@ export const auditEventLabels: Record = { "license.activated": "Activated license", "license.removed": "Removed license", + + "siem_destination.created": "Created SIEM destination", + "siem_destination.updated": "Updated SIEM destination", + "siem_destination.deleted": "Deleted SIEM destination", + + "template.created": "Created template", + "template.deleted": "Deleted template", }; export function getEventLabel(event: string): string { @@ -105,6 +114,10 @@ export const eventFilterOptions: EventGroup[] = [ { value: "user.activated", label: "Activated user" }, { value: "user.role_changed", label: "Changed user role" }, { value: "user.password_changed", label: "Changed password" }, + { + value: "user.password_reset_requested", + label: "Requested password reset", + }, { value: "user.mfa_enabled", label: "Enabled MFA" }, { value: "user.mfa_disabled", label: "Disabled MFA" }, ], @@ -147,6 +160,10 @@ export const eventFilterOptions: EventGroup[] = [ { value: "page.restriction_removed", label: "Removed page restriction" }, { value: "page.permission_added", label: "Added page permission" }, { value: "page.permission_removed", label: "Removed page permission" }, + { + value: "page.permission_role_changed", + label: "Changed page permission", + }, { value: "page.verification_created", label: "Created page verification" }, { value: "page.verification_updated", label: "Updated page verification" }, { value: "page.verification_removed", label: "Removed page verification" }, @@ -193,4 +210,19 @@ export const eventFilterOptions: EventGroup[] = [ { value: "license.removed", label: "Removed license" }, ], }, + { + group: "SIEM", + items: [ + { value: "siem_destination.created", label: "Created SIEM destination" }, + { value: "siem_destination.updated", label: "Updated SIEM destination" }, + { value: "siem_destination.deleted", label: "Deleted SIEM destination" }, + ], + }, + { + group: "Template", + items: [ + { value: "template.created", label: "Created template" }, + { value: "template.deleted", label: "Deleted template" }, + ], + }, ]; diff --git a/apps/client/src/ee/audit/pages/audit-logs.tsx b/apps/client/src/ee/audit/pages/audit-logs.tsx index 811e094e8..cd55ed43b 100644 --- a/apps/client/src/ee/audit/pages/audit-logs.tsx +++ b/apps/client/src/ee/audit/pages/audit-logs.tsx @@ -7,10 +7,12 @@ import { Popover, Select, Space, + Tabs, Text, Tooltip, } from "@mantine/core"; import { useTranslation } from "react-i18next"; +import { useLocation, useNavigate } from "react-router-dom"; import { IconSettings } from "@tabler/icons-react"; import SettingsTitle from "@/components/settings/settings-title"; import Paginate from "@/components/common/paginate"; @@ -23,6 +25,7 @@ import { import { IAuditLogParams } from "@/ee/audit/types/audit.types"; import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels"; import AuditLogsTable from "@/ee/audit/components/audit-logs-table"; +import SiemStreamingPanel from "@/ee/siem/components/siem-streaming-panel"; import useUserRole from "@/hooks/use-user-role"; import { DocumentTitle } from "@/components/ui/document-title.tsx"; @@ -48,6 +51,8 @@ export default function AuditLogs() { const { t } = useTranslation(); const { isOwner } = useUserRole(); const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate(); + const location = useLocation(); + const navigate = useNavigate(); const [eventFilter, setEventFilter] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); @@ -85,6 +90,8 @@ export default function AuditLogs() { const { data, isLoading } = useAuditLogsQuery(params); + const activeTab = location.pathname.endsWith("/siem") ? "siem" : "audit"; + if (!isOwner) { return null; } @@ -94,125 +101,150 @@ export default function AuditLogs() { resetCursor(); }; + const handleTabChange = (value: string | null) => { + if (value === "siem") { + navigate("/settings/audit/siem"); + } else { + navigate("/settings/audit"); + } + }; + return ( <> - + - + - - { - if (value === "days" || value === "months" || value === "years") { - setRetentionUnit(value); - } - }} - size="sm" - style={{ flex: 1 }} - comboboxProps={{ withinPortal: false }} - /> - - - - - - - - + + + { + if (value === "days" || value === "months" || value === "years") { + setRetentionUnit(value); + } + }} + size="sm" + style={{ flex: 1 }} + comboboxProps={{ withinPortal: false }} + /> + + + + + + + + - + - {data?.items && data.items.length > 0 && ( - goNext(data?.meta?.nextCursor)} - onPrev={goPrev} - /> - )} + + + {data?.items && data.items.length > 0 && ( + goNext(data?.meta?.nextCursor)} + onPrev={goPrev} + /> + )} + + + + + + ); } diff --git a/apps/client/src/ee/features.ts b/apps/client/src/ee/features.ts index 043849b49..a62462459 100644 --- a/apps/client/src/ee/features.ts +++ b/apps/client/src/ee/features.ts @@ -25,4 +25,5 @@ export const Feature = { OAUTH: 'oauth', AI_CONTROLS: 'ai:controls', MCP_CONTROLS: 'mcp:controls', + SIEM: 'siem', } as const; diff --git a/apps/client/src/ee/siem/components/delete-destination-modal.tsx b/apps/client/src/ee/siem/components/delete-destination-modal.tsx new file mode 100644 index 000000000..80fbc1bb3 --- /dev/null +++ b/apps/client/src/ee/siem/components/delete-destination-modal.tsx @@ -0,0 +1,44 @@ +import { Button, Group, Modal, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { ISiemDestination } from "@/ee/siem/types/siem.types"; +import { useDeleteSiemDestinationMutation } from "@/ee/siem/queries/siem-query"; + +interface DeleteDestinationModalProps { + opened: boolean; + onClose: () => void; + destination: ISiemDestination | null; +} + +export function DeleteDestinationModal({ opened, onClose, destination }: DeleteDestinationModalProps) { + const { t } = useTranslation(); + const deleteMutation = useDeleteSiemDestinationMutation(); + + const handleDelete = async () => { + if (!destination) return; + await deleteMutation.mutateAsync({ destinationId: destination.id }); + onClose(); + }; + + return ( + + + + {t("Are you sure you want to delete the destination")}{" "} + {destination?.name}? + + + + + + + + ); +} diff --git a/apps/client/src/ee/siem/components/destination-form-modal.tsx b/apps/client/src/ee/siem/components/destination-form-modal.tsx new file mode 100644 index 000000000..3fa7cf70e --- /dev/null +++ b/apps/client/src/ee/siem/components/destination-form-modal.tsx @@ -0,0 +1,300 @@ +import { useEffect, useState } from "react"; +import { + Alert, + Button, + Collapse, + Group, + Modal, + PasswordInput, + Select, + Stack, + Switch, + Text, + TextInput, +} from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { IconAlertCircle, IconCheck } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { isCloud } from "@/lib/config.ts"; +import { DATADOG_SITES, ISiemDestination, ISiemTestResult } from "@/ee/siem/types/siem.types"; +import { + useCreateSiemDestinationMutation, + useTestSiemDestinationMutation, + useUpdateSiemDestinationMutation, +} from "@/ee/siem/queries/siem-query"; +import { + DestinationFormValues, + initialValues, + toPayload, + validateForm, +} from "@/ee/siem/lib/destination-form"; +import { DESTINATION_TYPE_LABELS } from "./destination-table"; + +interface DestinationFormModalProps { + opened: boolean; + onClose: () => void; + destination?: ISiemDestination | null; +} + +function connectionKey(values: DestinationFormValues): string { + const { type, config, secrets } = toPayload(values); + return JSON.stringify({ type, config, secrets }); +} + +export function DestinationFormModal({ opened, onClose, destination }: DestinationFormModalProps) { + const { t } = useTranslation(); + const isEdit = Boolean(destination); + const hasSecrets = destination?.hasSecrets ?? {}; + const [advancedOpen, setAdvancedOpen] = useState(false); + const [testState, setTestState] = useState<{ result: ISiemTestResult | null; testedPayloadKey: string | null }>({ + result: null, + testedPayloadKey: null, + }); + const createMutation = useCreateSiemDestinationMutation(); + const updateMutation = useUpdateSiemDestinationMutation(); + const testMutation = useTestSiemDestinationMutation(); + + const form = useForm({ + initialValues: initialValues(destination), + validate: (values) => validateForm(values, hasSecrets), + }); + + useEffect(() => { + if (opened) { + form.setValues(initialValues(destination)); + form.resetDirty(); + // eslint-disable-next-line react-hooks/set-state-in-effect + setTestState({ result: null, testedPayloadKey: null }); + setAdvancedOpen(false); + } + }, [opened, destination?.id]); + + const handleSubmit = async (values: DestinationFormValues) => { + const payload = toPayload(values); + try { + if (destination) { + await updateMutation.mutateAsync({ + destinationId: destination.id, + name: payload.name, + config: payload.config, + secrets: payload.secrets, + enabled: payload.enabled, + }); + } else { + await createMutation.mutateAsync(payload); + } + onClose(); + } catch {} + }; + + const handleTest = async () => { + if (form.validate().hasErrors) return; + const payload = toPayload(form.values); + const testedPayloadKey = connectionKey(form.values); + setTestState((prev) => ({ ...prev, testedPayloadKey })); + try { + const result = await testMutation.mutateAsync({ + type: payload.type, + config: payload.config, + secrets: payload.secrets, + destinationId: destination?.id, + }); + setTestState({ result, testedPayloadKey }); + } catch { + setTestState({ result: null, testedPayloadKey }); + } + }; + + const type = form.values.type; + const showTls = type !== "datadog"; + const currentPayloadKey = connectionKey(form.values); + const showTestResult = testState.result !== null && testState.testedPayloadKey === currentPayloadKey; + const connectionChanged = currentPayloadKey !== connectionKey(initialValues(destination)); + const testPassed = showTestResult && testState.result.delivered; + const requiresTest = (!isEdit || connectionChanged) && !testPassed; + + return ( + +
+ + ({ value: site, label: site }))} + allowDeselect={false} + {...form.getInputProps("site")} + /> + + + )} + + {type === "http" && ( + <> + + + + )} + + + + + + {type === "splunk_hec" && ( + <> + + + + + + + + )} + + {type === "datadog" && ( + <> + + + + )} + + {type === "http" && ( + <> + + + + +