From 0d69d48c524596028f430031bf5ccdfb3710334f Mon Sep 17 00:00:00 2001
From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
Date: Fri, 4 Sep 2026 14:53:14 +0100
Subject: [PATCH] feat(ee): SIEM (#2471)
---
.../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 (
<>
-
+
-
+
-
-
+
+
+ ({
+ group: t(group.group),
+ items: group.items.map((item) => ({
+ value: item.value,
+ label: t(item.label),
+ })),
+ }))}
+ value={eventFilter}
+ onChange={handleEventChange}
+ clearable
+ searchable
+ w={220}
+ size="sm"
+ />
-
+ {
+ if (!opened) resetRetentionForm();
+ setSettingsOpen(opened);
+ }}
+ >
+
+
+ setSettingsOpen((o) => !o)}>
+
+
+
+
+
+
+ {t("Retention")}
+
+
+ {t("Logs older than this period are automatically deleted.")}
+
+
+ setRetentionAmount(val)}
+ min={1}
+ hideControls
+ size="sm"
+ w={60}
+ />
+ {
+ 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 (
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/siem/components/destination-status-badge.tsx b/apps/client/src/ee/siem/components/destination-status-badge.tsx
new file mode 100644
index 000000000..17618aab8
--- /dev/null
+++ b/apps/client/src/ee/siem/components/destination-status-badge.tsx
@@ -0,0 +1,15 @@
+import { Badge } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import { ISiemDestination } from "@/ee/siem/types/siem.types";
+
+export function DestinationStatusBadge({ destination }: { destination: ISiemDestination }) {
+ const { t } = useTranslation();
+
+ if (!destination.enabled) {
+ return {t("Disabled")};
+ }
+ if (destination.status === "failing") {
+ return {t("Failing")};
+ }
+ return {t("Healthy")};
+}
diff --git a/apps/client/src/ee/siem/components/destination-table.tsx b/apps/client/src/ee/siem/components/destination-table.tsx
new file mode 100644
index 000000000..c2d540a43
--- /dev/null
+++ b/apps/client/src/ee/siem/components/destination-table.tsx
@@ -0,0 +1,155 @@
+import { ActionIcon, Menu, Switch, Table, Text, Tooltip } from "@mantine/core";
+import {
+ IconDots,
+ IconEdit,
+ IconPlugConnected,
+ IconRefresh,
+ IconTrash,
+} from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { formattedDate, timeAgo } from "@/lib/time.ts";
+import { ISiemDestination, SiemDestinationType } from "@/ee/siem/types/siem.types";
+import { DestinationStatusBadge } from "./destination-status-badge";
+
+export const DESTINATION_TYPE_LABELS: Record = {
+ splunk_hec: "Splunk HEC",
+ datadog: "Datadog",
+ http: "Generic HTTP",
+};
+
+interface DestinationTableProps {
+ destinations?: ISiemDestination[];
+ isLoading?: boolean;
+ onEdit: (destination: ISiemDestination) => void;
+ onTest: (destination: ISiemDestination) => void;
+ onRetry: (destination: ISiemDestination) => void;
+ onDelete: (destination: ISiemDestination) => void;
+ onToggle: (destination: ISiemDestination, enabled: boolean) => void;
+}
+
+export function DestinationTable({
+ destinations,
+ isLoading,
+ onEdit,
+ onTest,
+ onRetry,
+ onDelete,
+ onToggle,
+}: DestinationTableProps) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+ {t("Name")}
+ {t("Type")}
+ {t("Enabled")}
+ {t("Status")}
+ {t("Last delivered")}
+ {t("Last error")}
+
+
+
+
+ {destinations && destinations.length > 0 ? (
+ destinations.map((destination) => (
+
+
+ {destination.name}
+
+
+ {DESTINATION_TYPE_LABELS[destination.type]}
+
+
+ onToggle(destination, event.currentTarget.checked)}
+ aria-label={t("Enabled")}
+ />
+
+
+
+ {destination.failingSince &&
+ (destination.status === "failing" ||
+ !destination.enabled) && (
+
+ {t("Failing since {{time}}", {
+ time: formattedDate(
+ new Date(destination.failingSince),
+ ),
+ })}
+
+ )}
+
+
+
+ {destination.lastDeliveredAt
+ ? timeAgo(new Date(destination.lastDeliveredAt))
+ : t("Never")}
+
+
+
+ {destination.lastError ? (
+
+
+ {destination.lastError}
+
+
+ ) : (
+ —
+ )}
+
+
+
+
+
+ ))
+ ) : (
+ !isLoading && (
+
+
+
+ {t("No destinations yet")}
+
+
+
+ )
+ )}
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/siem/components/siem-streaming-panel.tsx b/apps/client/src/ee/siem/components/siem-streaming-panel.tsx
new file mode 100644
index 000000000..e54230de3
--- /dev/null
+++ b/apps/client/src/ee/siem/components/siem-streaming-panel.tsx
@@ -0,0 +1,129 @@
+import { useState } from "react";
+import { Alert, Button, Group, Tooltip } from "@mantine/core";
+import { notifications } from "@mantine/notifications";
+import { IconAlertCircle, IconInfoCircle } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import useUserRole from "@/hooks/use-user-role";
+import { useHasFeature } from "@/ee/hooks/use-feature";
+import { Feature } from "@/ee/features";
+import {
+ ISiemDestination,
+ SIEM_MAX_DESTINATIONS_PER_WORKSPACE,
+} from "@/ee/siem/types/siem.types";
+import {
+ useRetrySiemDestinationMutation,
+ useSiemDestinationsQuery,
+ extractErrorMessage,
+ useTestSiemDestinationMutation,
+ useUpdateSiemDestinationMutation,
+} from "@/ee/siem/queries/siem-query";
+import { DestinationTable } from "@/ee/siem/components/destination-table";
+import { DestinationFormModal } from "@/ee/siem/components/destination-form-modal";
+import { DeleteDestinationModal } from "@/ee/siem/components/delete-destination-modal";
+
+export default function SiemStreamingPanel() {
+ const { t } = useTranslation();
+ const { isOwner } = useUserRole();
+ const hasFeature = useHasFeature(Feature.SIEM);
+ const { data, isLoading, isError, error } = useSiemDestinationsQuery(hasFeature);
+ const updateMutation = useUpdateSiemDestinationMutation();
+ const retryMutation = useRetrySiemDestinationMutation();
+ const testMutation = useTestSiemDestinationMutation();
+ const [formOpened, setFormOpened] = useState(false);
+ const [deleteOpened, setDeleteOpened] = useState(false);
+ const [selected, setSelected] = useState(null);
+
+ if (!isOwner) {
+ return null;
+ }
+
+ const atDestinationLimit =
+ (data?.length ?? 0) >= SIEM_MAX_DESTINATIONS_PER_WORKSPACE;
+
+ const handleTest = async (destination: ISiemDestination) => {
+ const result = await testMutation
+ .mutateAsync({
+ type: destination.type,
+ config: destination.config as unknown as Record,
+ destinationId: destination.id,
+ })
+ .catch(() => null);
+ if (!result) return;
+ notifications.show({
+ message: result.delivered
+ ? t("Test event delivered to {{name}}", { name: destination.name })
+ : result.error,
+ color: result.delivered ? "green" : "red",
+ });
+ };
+
+ return (
+ <>
+ {!hasFeature && (
+ } color="yellow" mb="md">
+ {t("SIEM streaming requires an Enterprise license.")}
+
+ )}
+
+
+
+
+
+
+
+
+
+ {isError && (
+ } color="red" mb="md">
+ {t("Could not load SIEM destinations: {{message}}", {
+ message: extractErrorMessage(error),
+ })}
+
+ )}
+
+ {hasFeature && !isError && (
+ {
+ setSelected(destination);
+ setFormOpened(true);
+ }}
+ onTest={handleTest}
+ onRetry={(destination) => retryMutation.mutate({ destinationId: destination.id })}
+ onDelete={(destination) => {
+ setSelected(destination);
+ setDeleteOpened(true);
+ }}
+ onToggle={(destination, enabled) =>
+ updateMutation.mutate({ destinationId: destination.id, enabled })
+ }
+ />
+ )}
+
+ setFormOpened(false)}
+ destination={selected}
+ />
+ setDeleteOpened(false)}
+ destination={selected}
+ />
+ >
+ );
+}
diff --git a/apps/client/src/ee/siem/lib/destination-form.ts b/apps/client/src/ee/siem/lib/destination-form.ts
new file mode 100644
index 000000000..4a52cc4f6
--- /dev/null
+++ b/apps/client/src/ee/siem/lib/destination-form.ts
@@ -0,0 +1,179 @@
+import {
+ DATADOG_SITES,
+ ISiemDestination,
+ ISiemDestinationInput,
+ SiemDestinationType,
+} from "@/ee/siem/types/siem.types";
+
+export type DestinationFormValues = {
+ name: string;
+ type: SiemDestinationType;
+ url: string;
+ token: string;
+ apiKey: string;
+ authHeaderName: string;
+ authHeaderPrefix: string;
+ format: "json" | "ndjson";
+ index: string;
+ source: string;
+ sourcetype: string;
+ host: string;
+ site: string;
+ service: string;
+ tags: string;
+ rejectUnauthorized: boolean;
+ enabled: boolean;
+};
+
+export const DEFAULT_FORM_VALUES: DestinationFormValues = {
+ name: "",
+ type: "splunk_hec",
+ url: "",
+ token: "",
+ apiKey: "",
+ authHeaderName: "Authorization",
+ authHeaderPrefix: "Bearer ",
+ format: "json",
+ index: "",
+ source: "docmost",
+ sourcetype: "docmost:audit",
+ host: "",
+ site: DATADOG_SITES[0],
+ service: "docmost",
+ tags: "",
+ rejectUnauthorized: true,
+ enabled: true,
+};
+
+const HEADER_NAME_RE = /^[A-Za-z0-9-]+$/;
+export const SECRET_MASK = "********";
+
+export function initialValues(
+ destination?: ISiemDestination | null,
+): DestinationFormValues {
+ if (!destination) return { ...DEFAULT_FORM_VALUES };
+ const config = destination.config as Record;
+ const tls = config.tls ?? {};
+ return {
+ ...DEFAULT_FORM_VALUES,
+ name: destination.name,
+ type: destination.type,
+ enabled: destination.enabled,
+ token: destination.hasSecrets?.token ? SECRET_MASK : "",
+ apiKey: destination.hasSecrets?.apiKey ? SECRET_MASK : "",
+ url: config.url ?? "",
+ authHeaderName: config.authHeaderName ?? DEFAULT_FORM_VALUES.authHeaderName,
+ authHeaderPrefix: config.authHeaderPrefix ?? DEFAULT_FORM_VALUES.authHeaderPrefix,
+ format: config.format ?? "json",
+ index: config.index ?? "",
+ source: config.source ?? DEFAULT_FORM_VALUES.source,
+ sourcetype: config.sourcetype ?? DEFAULT_FORM_VALUES.sourcetype,
+ host: config.host ?? "",
+ site: config.site ?? DEFAULT_FORM_VALUES.site,
+ service: config.service ?? DEFAULT_FORM_VALUES.service,
+ tags: config.tags ?? "",
+ rejectUnauthorized: tls.rejectUnauthorized ?? true,
+ };
+}
+
+function isValidUrl(value: string): boolean {
+ try {
+ const url = new URL(value);
+ return url.protocol === "http:" || url.protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
+export function validateForm(
+ values: DestinationFormValues,
+ hasSecrets: Record = {},
+): Partial> {
+ const errors: Partial> = {};
+
+ if (!values.name.trim()) errors.name = "Name is required";
+
+ if (values.type !== "datadog" && !isValidUrl(values.url.trim())) {
+ errors.url = "Enter a valid http(s) URL";
+ }
+
+ if (values.type === "splunk_hec") {
+ if (!values.token && !hasSecrets.token) {
+ errors.token = "HEC token is required";
+ }
+ try {
+ const url = new URL(values.url.trim());
+ const path = url.pathname.replace(/\/+$/, "");
+ if (path !== "" && path !== "/services/collector" && path !== "/services/collector/event") {
+ errors.url = "Enter the HEC base URL or the /services/collector/event endpoint";
+ }
+ } catch {}
+ }
+
+ if (values.type === "datadog") {
+ if (!(DATADOG_SITES as readonly string[]).includes(values.site)) {
+ errors.site = "Select a Datadog site";
+ }
+ if (!values.apiKey && !hasSecrets.apiKey) errors.apiKey = "API key is required";
+ }
+
+ if (values.type === "http") {
+ if (!HEADER_NAME_RE.test(values.authHeaderName.trim())) {
+ errors.authHeaderName = "Use letters, digits and hyphens only";
+ }
+ }
+
+
+ return errors;
+}
+
+function enteredSecret(key: string, value: string): Record {
+ const trimmed = value.trim();
+ return trimmed && trimmed !== SECRET_MASK ? { [key]: trimmed } : {};
+}
+
+export function toPayload(values: DestinationFormValues): ISiemDestinationInput {
+ const tls = { rejectUnauthorized: values.rejectUnauthorized };
+
+ let config: Record;
+ let secrets: Record;
+
+ switch (values.type) {
+ case "splunk_hec":
+ config = {
+ url: values.url.trim(),
+ index: values.index.trim(),
+ source: values.source.trim() || "docmost",
+ sourcetype: values.sourcetype.trim() || "docmost:audit",
+ host: values.host.trim(),
+ tls,
+ };
+ secrets = enteredSecret("token", values.token);
+ break;
+ case "datadog":
+ config = {
+ site: values.site,
+ service: values.service.trim() || "docmost",
+ tags: values.tags.trim(),
+ };
+ secrets = enteredSecret("apiKey", values.apiKey);
+ break;
+ default:
+ config = {
+ url: values.url.trim(),
+ authHeaderName: values.authHeaderName.trim(),
+ authHeaderPrefix: values.authHeaderPrefix,
+ format: values.format,
+ tls,
+ };
+ secrets = enteredSecret("token", values.token);
+ }
+
+ return {
+ name: values.name.trim(),
+ type: values.type,
+ config,
+ secrets: Object.fromEntries(Object.entries(secrets).filter(([, v]) => v !== "")),
+ enabled: values.enabled,
+ };
+}
diff --git a/apps/client/src/ee/siem/queries/siem-query.ts b/apps/client/src/ee/siem/queries/siem-query.ts
new file mode 100644
index 000000000..e3f814c04
--- /dev/null
+++ b/apps/client/src/ee/siem/queries/siem-query.ts
@@ -0,0 +1,115 @@
+import {
+ useMutation,
+ useQuery,
+ useQueryClient,
+ UseQueryResult,
+} from "@tanstack/react-query";
+import { notifications } from "@mantine/notifications";
+import { useTranslation } from "react-i18next";
+import {
+ createSiemDestination,
+ deleteSiemDestination,
+ getSiemDestinations,
+ retrySiemDestination,
+ testSiemDestination,
+ updateSiemDestination,
+} from "@/ee/siem/services/siem-service";
+import {
+ ISiemDestination,
+ ISiemDestinationInput,
+ ISiemTestResult,
+ ITestSiemDestinationInput,
+ IUpdateSiemDestinationInput,
+} from "@/ee/siem/types/siem.types";
+
+export const SIEM_DESTINATIONS_KEY = ["siem-destinations"];
+
+export function extractErrorMessage(error: Error): string {
+ const data = (error as any)?.response?.data;
+ const message = data?.message ?? error.message;
+ return Array.isArray(message) ? message.join(", ") : String(message);
+}
+
+function showError(error: Error) {
+ notifications.show({ message: extractErrorMessage(error), color: "red" });
+}
+
+function isForbidden(error: unknown): boolean {
+ return (error as { response?: { status?: number } })?.response?.status === 403;
+}
+
+export function useSiemDestinationsQuery(
+ enabled = true,
+): UseQueryResult {
+ return useQuery({
+ queryKey: SIEM_DESTINATIONS_KEY,
+ queryFn: getSiemDestinations,
+ enabled,
+ retry: (failureCount, error) => !isForbidden(error) && failureCount < 2,
+ refetchInterval: (query) => (query.state.status === "error" ? false : 15_000),
+ });
+}
+
+function useInvalidateDestinations() {
+ const queryClient = useQueryClient();
+ return () => queryClient.invalidateQueries({ queryKey: SIEM_DESTINATIONS_KEY });
+}
+
+export function useCreateSiemDestinationMutation() {
+ const { t } = useTranslation();
+ const invalidate = useInvalidateDestinations();
+ return useMutation({
+ mutationFn: createSiemDestination,
+ onSuccess: () => {
+ notifications.show({ message: t("Destination created") });
+ invalidate();
+ },
+ onError: showError,
+ });
+}
+
+export function useUpdateSiemDestinationMutation() {
+ const { t } = useTranslation();
+ const invalidate = useInvalidateDestinations();
+ return useMutation({
+ mutationFn: updateSiemDestination,
+ onSuccess: () => {
+ notifications.show({ message: t("Destination updated") });
+ invalidate();
+ },
+ onError: showError,
+ });
+}
+
+export function useDeleteSiemDestinationMutation() {
+ const { t } = useTranslation();
+ const invalidate = useInvalidateDestinations();
+ return useMutation({
+ mutationFn: deleteSiemDestination,
+ onSuccess: () => {
+ notifications.show({ message: t("Destination deleted") });
+ invalidate();
+ },
+ onError: showError,
+ });
+}
+
+export function useRetrySiemDestinationMutation() {
+ const { t } = useTranslation();
+ const invalidate = useInvalidateDestinations();
+ return useMutation({
+ mutationFn: retrySiemDestination,
+ onSuccess: () => {
+ notifications.show({ message: t("Retry scheduled") });
+ invalidate();
+ },
+ onError: showError,
+ });
+}
+
+export function useTestSiemDestinationMutation() {
+ return useMutation({
+ mutationFn: testSiemDestination,
+ onError: showError,
+ });
+}
diff --git a/apps/client/src/ee/siem/services/siem-service.ts b/apps/client/src/ee/siem/services/siem-service.ts
new file mode 100644
index 000000000..0c394ecf3
--- /dev/null
+++ b/apps/client/src/ee/siem/services/siem-service.ts
@@ -0,0 +1,46 @@
+import api from "@/lib/api-client";
+import {
+ ISiemDestination,
+ ISiemDestinationInput,
+ ISiemTestResult,
+ ITestSiemDestinationInput,
+ IUpdateSiemDestinationInput,
+} from "@/ee/siem/types/siem.types";
+
+export async function getSiemDestinations(): Promise {
+ const req = await api.post("/siem/destinations");
+ return req.data;
+}
+
+export async function createSiemDestination(
+ data: ISiemDestinationInput,
+): Promise {
+ const req = await api.post("/siem/destinations/create", data);
+ return req.data;
+}
+
+export async function updateSiemDestination(
+ data: IUpdateSiemDestinationInput,
+): Promise {
+ const req = await api.post("/siem/destinations/update", data);
+ return req.data;
+}
+
+export async function deleteSiemDestination(data: {
+ destinationId: string;
+}): Promise {
+ await api.post("/siem/destinations/delete", data);
+}
+
+export async function testSiemDestination(
+ data: ITestSiemDestinationInput,
+): Promise {
+ const req = await api.post("/siem/destinations/test", data);
+ return req.data;
+}
+
+export async function retrySiemDestination(data: {
+ destinationId: string;
+}): Promise {
+ await api.post("/siem/destinations/retry", data);
+}
diff --git a/apps/client/src/ee/siem/types/siem.types.ts b/apps/client/src/ee/siem/types/siem.types.ts
new file mode 100644
index 000000000..7e8a17232
--- /dev/null
+++ b/apps/client/src/ee/siem/types/siem.types.ts
@@ -0,0 +1,91 @@
+export const SIEM_MAX_DESTINATIONS_PER_WORKSPACE = 2;
+
+export type SiemDestinationType = "http" | "splunk_hec" | "datadog";
+export type SiemDestinationStatus = "healthy" | "failing";
+
+export const DATADOG_SITES = [
+ "datadoghq.com",
+ "datadoghq.eu",
+ "us3.datadoghq.com",
+ "us5.datadoghq.com",
+ "ap1.datadoghq.com",
+ "ddog-gov.com",
+] as const;
+
+export interface ITlsOptions {
+ rejectUnauthorized: boolean;
+}
+
+export interface IHttpConfig {
+ url: string;
+ authHeaderName: string;
+ authHeaderPrefix: string;
+ format: "json" | "ndjson";
+ tls?: ITlsOptions;
+}
+
+export interface ISplunkHecConfig {
+ url: string;
+ index?: string;
+ source: string;
+ sourcetype: string;
+ host?: string;
+ channelId: string;
+ tls?: ITlsOptions;
+}
+
+export interface IDatadogConfig {
+ site: string;
+ service: string;
+ tags?: string;
+}
+
+export type ISiemConfig = IHttpConfig | ISplunkHecConfig | IDatadogConfig;
+
+export interface ISiemDestination {
+ id: string;
+ name: string;
+ type: SiemDestinationType;
+ enabled: boolean;
+ status: SiemDestinationStatus;
+ config: ISiemConfig;
+ hasSecrets: Record;
+ cursorCreatedAt: string;
+ lastDeliveredAt: string | null;
+ lastError: string | null;
+ lastErrorAt: string | null;
+ consecutiveFailures: number;
+ nextAttemptAt: string | null;
+ failingSince: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface ISiemDestinationInput {
+ name: string;
+ type: SiemDestinationType;
+ config: Record;
+ secrets?: Record;
+ enabled?: boolean;
+}
+
+export interface IUpdateSiemDestinationInput {
+ destinationId: string;
+ name?: string;
+ config?: Record;
+ secrets?: Record;
+ enabled?: boolean;
+}
+
+export interface ITestSiemDestinationInput {
+ type: SiemDestinationType;
+ config: Record;
+ secrets?: Record;
+ destinationId?: string;
+}
+
+export interface ISiemTestResult {
+ delivered: boolean;
+ error?: string;
+ statusCode?: number;
+}
diff --git a/apps/client/src/features/notification/components/notification-item.tsx b/apps/client/src/features/notification/components/notification-item.tsx
index 418647375..12b9f2428 100644
--- a/apps/client/src/features/notification/components/notification-item.tsx
+++ b/apps/client/src/features/notification/components/notification-item.tsx
@@ -63,6 +63,12 @@ export function NotificationItem({
return "Page verification expires soon";
case "page.verification_expired":
return "Page verification has expired";
+ case "siem_destination.failing":
+ return "SIEM destination {{name}} is failing";
+ case "siem_destination.disabled":
+ return "SIEM destination {{name}} was disabled after 24 hours of failures";
+ case "siem_destination.recovered":
+ return "SIEM destination {{name}} recovered";
default:
return "";
}
@@ -77,6 +83,19 @@ export function NotificationItem({
)
: undefined;
+ const isSiemDestination = notification.type.startsWith("siem_destination.");
+ const destinationName =
+ typeof notification.data?.destinationName === "string"
+ ? notification.data.destinationName
+ : "";
+ const lastError =
+ (notification.type === "siem_destination.failing" ||
+ notification.type === "siem_destination.disabled") &&
+ typeof notification.data?.lastError === "string"
+ ? notification.data.lastError
+ : null;
+ const linkUrl = isSiemDestination ? "/settings/audit/siem" : pageUrl;
+
const markReadIfNeeded = () => {
if (isUnread) {
markRead.mutate([notification.id]);
@@ -97,7 +116,7 @@ export function NotificationItem({
return (
}}
/>
+ {lastError && (
+
+ {lastError}
+
+ )}
+
{notification.page && (
{notification.page.icon ? (
diff --git a/apps/client/src/features/notification/types/notification.types.ts b/apps/client/src/features/notification/types/notification.types.ts
index 266b9a6e4..e5eb6f658 100644
--- a/apps/client/src/features/notification/types/notification.types.ts
+++ b/apps/client/src/features/notification/types/notification.types.ts
@@ -9,7 +9,10 @@ export type NotificationType =
| "page.verification_expired"
| "page.verified"
| "page.approval_requested"
- | "page.approval_rejected";
+ | "page.approval_rejected"
+ | "siem_destination.failing"
+ | "siem_destination.disabled"
+ | "siem_destination.recovered";
export type INotification = {
id: string;
diff --git a/apps/server/package.json b/apps/server/package.json
index c0a9ec88d..bc5f6543d 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -161,7 +161,8 @@
"moduleFileExtensions": [
"js",
"json",
- "ts"
+ "ts",
+ "tsx"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
@@ -181,7 +182,7 @@
]
}
],
- "^.+\\.(t|j)s$": "ts-jest"
+ "^.+\\.(t|j)sx?$": "ts-jest"
},
"transformIgnorePatterns": [
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom)(@|/))"
diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts
index 2de94a662..5002ca8a7 100644
--- a/apps/server/src/app.module.ts
+++ b/apps/server/src/app.module.ts
@@ -28,6 +28,7 @@ import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
import { ThrottleModule } from './integrations/throttle/throttle.module';
+import { OutboundModule } from './integrations/outbound/outbound.module';
import { EncryptionModule } from './integrations/encryption/encryption.module';
const enterpriseModules = [];
@@ -51,7 +52,7 @@ try {
middleware: { mount: true },
}),
LoggerModule,
- NoopAuditModule,
+ ...(enterpriseModules.length > 0 ? [] : [NoopAuditModule]),
CoreModule,
DatabaseModule,
EnvironmentModule,
@@ -98,6 +99,7 @@ try {
SecurityModule,
TelemetryModule,
ThrottleModule,
+ OutboundModule,
...enterpriseModules,
],
controllers: [AppController],
diff --git a/apps/server/src/common/events/audit-events.ts b/apps/server/src/common/events/audit-events.ts
index 24ca2af5d..7d088791b 100644
--- a/apps/server/src/common/events/audit-events.ts
+++ b/apps/server/src/common/events/audit-events.ts
@@ -14,6 +14,7 @@ export const AuditEvent = {
USER_ROLE_CHANGED: 'user.role_changed',
USER_PASSWORD_CHANGED: 'user.password_changed',
USER_PASSWORD_RESET: 'user.password_reset',
+ USER_PASSWORD_RESET_REQUESTED: 'user.password_reset_requested',
USER_UPDATED: 'user.updated',
USER_DEACTIVATED: 'user.deactivated',
USER_ACTIVATED: 'user.activated',
@@ -69,6 +70,7 @@ export const AuditEvent = {
PAGE_RESTRICTION_REMOVED: 'page.restriction_removed',
PAGE_PERMISSION_ADDED: 'page.permission_added',
PAGE_PERMISSION_REMOVED: 'page.permission_removed',
+ PAGE_PERMISSION_ROLE_CHANGED: 'page.permission_role_changed',
// Page verification
PAGE_VERIFICATION_CREATED: 'page.verification_created',
PAGE_VERIFICATION_UPDATED: 'page.verification_updated',
@@ -104,6 +106,16 @@ export const AuditEvent = {
// Attachment
ATTACHMENT_UPLOADED: 'attachment.uploaded',
// ATTACHMENT_DELETED: 'attachment.deleted',
+
+ // SIEM streaming
+ SIEM_DESTINATION_CREATED: 'siem_destination.created',
+ SIEM_DESTINATION_UPDATED: 'siem_destination.updated',
+ SIEM_DESTINATION_DELETED: 'siem_destination.deleted',
+ SIEM_DESTINATION_TEST: 'siem_destination.test',
+
+ // Template
+ TEMPLATE_CREATED: 'template.created',
+ TEMPLATE_DELETED: 'template.deleted',
} as const;
export type AuditEventType = (typeof AuditEvent)[keyof typeof AuditEvent];
@@ -116,7 +128,8 @@ export const EXCLUDED_AUDIT_EVENTS: Set = new Set([
AuditEvent.COMMENT_UPDATED,
AuditEvent.COMMENT_RESOLVED,
AuditEvent.COMMENT_REOPENED,
- AuditEvent.ATTACHMENT_UPLOADED
+ AuditEvent.ATTACHMENT_UPLOADED,
+ AuditEvent.SIEM_DESTINATION_TEST,
]);
export const AuditResource = {
@@ -136,6 +149,8 @@ export const AuditResource = {
WORKSPACE_INVITATION: 'workspace_invitation',
ATTACHMENT: 'attachment',
LICENSE: 'license',
+ SIEM_DESTINATION: 'siem_destination',
+ TEMPLATE: 'template',
} as const;
export type AuditResourceType =
diff --git a/apps/server/src/common/features.ts b/apps/server/src/common/features.ts
index fcb21d35a..0afb3ca61 100644
--- a/apps/server/src/common/features.ts
+++ b/apps/server/src/common/features.ts
@@ -26,6 +26,7 @@ export const Feature = {
OAUTH: 'oauth',
AI_CONTROLS: 'ai:controls',
MCP_CONTROLS: 'mcp:controls',
+ SIEM: 'siem',
} as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
diff --git a/apps/server/src/common/helpers/cache-keys.ts b/apps/server/src/common/helpers/cache-keys.ts
index 38b24d20e..394ab173f 100644
--- a/apps/server/src/common/helpers/cache-keys.ts
+++ b/apps/server/src/common/helpers/cache-keys.ts
@@ -4,6 +4,7 @@ export const CacheKey = {
`perm:space-roles:${userId}:${spaceId}`,
PAGE_CAN_EDIT: (userId: string, pageId: string) =>
`perm:can-edit:${userId}:${pageId}`,
+ SIEM_LICENSED: (workspaceId: string) => `siem:licensed:${workspaceId}`,
};
// Permission caches dedupe repeated checks within and across short request bursts.
diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts
index 45148931e..303245045 100644
--- a/apps/server/src/core/auth/services/auth.service.ts
+++ b/apps/server/src/core/auth/services/auth.service.ts
@@ -219,6 +219,13 @@ export class AuthService {
subject: 'Reset your password',
template: emailTemplate,
});
+
+ this.auditService.log({
+ event: AuditEvent.USER_PASSWORD_RESET_REQUESTED,
+ resourceType: AuditResource.USER,
+ resourceId: user.id,
+ metadata: { source: 'forgot_password' },
+ });
}
async passwordReset(
diff --git a/apps/server/src/core/notification/notification.constants.ts b/apps/server/src/core/notification/notification.constants.ts
index fc42bc64d..894b98e4c 100644
--- a/apps/server/src/core/notification/notification.constants.ts
+++ b/apps/server/src/core/notification/notification.constants.ts
@@ -10,6 +10,9 @@ export const NotificationType = {
PAGE_VERIFIED: 'page.verified',
PAGE_APPROVAL_REQUESTED: 'page.approval_requested',
PAGE_APPROVAL_REJECTED: 'page.approval_rejected',
+ SIEM_DESTINATION_FAILING: 'siem_destination.failing',
+ SIEM_DESTINATION_DISABLED: 'siem_destination.disabled',
+ SIEM_DESTINATION_RECOVERED: 'siem_destination.recovered',
} as const;
export type NotificationType =
@@ -40,6 +43,9 @@ export const DIRECT_NOTIFICATION_TYPES: NotificationType[] = [
NotificationType.COMMENT_RESOLVED,
NotificationType.PAGE_USER_MENTION,
NotificationType.PAGE_PERMISSION_GRANTED,
+ NotificationType.SIEM_DESTINATION_FAILING,
+ NotificationType.SIEM_DESTINATION_DISABLED,
+ NotificationType.SIEM_DESTINATION_RECOVERED,
];
export const UPDATES_NOTIFICATION_TYPES: NotificationType[] = [
diff --git a/apps/server/src/database/migrations/20260902T121326-siem-destinations.ts b/apps/server/src/database/migrations/20260902T121326-siem-destinations.ts
new file mode 100644
index 000000000..7b76cacea
--- /dev/null
+++ b/apps/server/src/database/migrations/20260902T121326-siem-destinations.ts
@@ -0,0 +1,66 @@
+import { Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .createTable('siem_destinations')
+ .ifNotExists()
+ .addColumn('id', 'uuid', (col) =>
+ col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
+ )
+ .addColumn('workspace_id', 'uuid', (col) =>
+ col.notNull().references('workspaces.id').onDelete('cascade'),
+ )
+ .addColumn('name', 'varchar', (col) => col.notNull())
+ .addColumn('type', 'varchar', (col) => col.notNull())
+ .addColumn('enabled', 'boolean', (col) => col.notNull().defaultTo(true))
+ .addColumn('config', 'jsonb', (col) => col.notNull())
+ .addColumn('secrets', 'text', (col) => col.notNull())
+ .addColumn('cursor_created_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('cursor_id', 'uuid', (col) =>
+ col.notNull().defaultTo(sql`gen_uuid_v7()`),
+ )
+ .addColumn('cursor_snapshot', 'text')
+ // Fences cursor writes from stale jobs after configuration changes.
+ .addColumn('version', 'integer', (col) => col.notNull().defaultTo(0))
+ .addColumn('status', 'varchar', (col) => col.notNull().defaultTo('healthy'))
+ .addColumn('consecutive_failures', 'integer', (col) =>
+ col.notNull().defaultTo(0),
+ )
+ .addColumn('next_attempt_at', 'timestamptz')
+ .addColumn('last_delivered_at', 'timestamptz')
+ .addColumn('last_error', 'text')
+ .addColumn('last_error_at', 'timestamptz')
+ .addColumn('failing_since', 'timestamptz')
+ .addColumn('creator_id', 'uuid', (col) =>
+ col.references('users.id').onDelete('set null'),
+ )
+ .addColumn('created_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('updated_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .execute();
+
+ await db.schema
+ .createIndex('idx_siem_destinations_workspace_id')
+ .ifNotExists()
+ .on('siem_destinations')
+ .columns(['workspace_id'])
+ .execute();
+
+ await sql`
+ CREATE INDEX IF NOT EXISTS idx_siem_destinations_due
+ ON siem_destinations (next_attempt_at)
+ WHERE enabled = true
+ `.execute(db);
+
+ await db.schema.alterTable('audit').addColumn('user_agent', 'text').execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema.alterTable('audit').dropColumn('user_agent').execute();
+ await db.schema.dropTable('siem_destinations').ifExists().execute();
+}
diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts
index 25060c71a..373eadbf5 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -74,6 +74,7 @@ export interface Audit {
resourceId: string | null;
resourceType: string;
spaceId: string | null;
+ userAgent: string | null;
workspaceId: string;
}
@@ -361,6 +362,30 @@ export interface SpaceMembers {
userId: string | null;
}
+export interface SiemDestinations {
+ config: Json;
+ consecutiveFailures: Generated;
+ createdAt: Generated;
+ creatorId: string | null;
+ cursorCreatedAt: Generated;
+ cursorId: Generated;
+ cursorSnapshot: string | null;
+ enabled: Generated;
+ failingSince: Timestamp | null;
+ id: Generated;
+ lastDeliveredAt: Timestamp | null;
+ lastError: string | null;
+ lastErrorAt: Timestamp | null;
+ name: string;
+ nextAttemptAt: Timestamp | null;
+ secrets: string;
+ status: Generated;
+ type: string;
+ updatedAt: Generated;
+ version: Generated;
+ workspaceId: string;
+}
+
export interface Spaces {
createdAt: Generated;
creatorId: string | null;
@@ -724,6 +749,7 @@ export interface DB {
pages: Pages;
scimTokens: ScimTokens;
shares: Shares;
+ siemDestinations: SiemDestinations;
spaceMembers: SpaceMembers;
spaces: Spaces;
templates: Templates;
diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts
index c7d2af0a0..6fd09a963 100644
--- a/apps/server/src/database/types/entity.types.ts
+++ b/apps/server/src/database/types/entity.types.ts
@@ -37,6 +37,7 @@ import {
UserSessions,
ApiKeys,
ScimTokens,
+ SiemDestinations,
Watchers,
Audit as _Audit,
Templates,
@@ -267,3 +268,8 @@ export type UpdatableBaseRow = Updateable>;
export type BaseView = Selectable;
export type InsertableBaseView = Insertable;
export type UpdatableBaseView = Updateable>;
+
+// SIEM destinations
+export type SiemDestination = Selectable;
+export type InsertableSiemDestination = Insertable;
+export type UpdatableSiemDestination = Updateable>;
diff --git a/apps/server/src/ee b/apps/server/src/ee
index cbf35d363..c8ca1467d 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit cbf35d363a1745bc09e7e8c72ca1fd63cf1b7b5c
+Subproject commit c8ca1467dfdde0499b6a6fc21c3425d648ba6a2e
diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts
index c483df626..bb11eafe2 100644
--- a/apps/server/src/integrations/environment/environment.service.ts
+++ b/apps/server/src/integrations/environment/environment.service.ts
@@ -385,4 +385,8 @@ export class EnvironmentService {
.map((o) => o.trim())
.filter(Boolean);
}
+
+ getAllowedPrivateNetworks(): string {
+ return this.configService.get('ALLOWED_PRIVATE_NETWORKS', 'none');
+ }
}
diff --git a/apps/server/src/integrations/outbound/outbound-agent.factory.spec.ts b/apps/server/src/integrations/outbound/outbound-agent.factory.spec.ts
new file mode 100644
index 000000000..6efcd645e
--- /dev/null
+++ b/apps/server/src/integrations/outbound/outbound-agent.factory.spec.ts
@@ -0,0 +1,23 @@
+import { Agent } from 'undici';
+import { OutboundAgentFactory } from './outbound-agent.factory';
+import { OutboundUrlError } from './outbound-url.guard';
+
+describe('OutboundAgentFactory', () => {
+ it('validates the URL through the guard and returns a releasable undici Agent', async () => {
+ const validate = jest.fn().mockResolvedValue({ hostname: 'siem.example.com', address: '203.0.113.5', family: 4 });
+ const factory = new OutboundAgentFactory({ validate } as any);
+
+ const lease = await factory.lease('https://siem.example.com/ingest', { caCert: undefined, rejectUnauthorized: true });
+
+ expect(validate).toHaveBeenCalledWith('https://siem.example.com/ingest');
+ expect(lease.dispatcher).toBeInstanceOf(Agent);
+ await expect(lease.release()).resolves.toBeUndefined();
+ });
+
+ it('propagates guard rejections', async () => {
+ const validate = jest.fn().mockRejectedValue(new OutboundUrlError('Destination URL must use https'));
+ const factory = new OutboundAgentFactory({ validate } as any);
+
+ await expect(factory.lease('http://siem.example.com')).rejects.toThrow(OutboundUrlError);
+ });
+});
diff --git a/apps/server/src/integrations/outbound/outbound-agent.factory.ts b/apps/server/src/integrations/outbound/outbound-agent.factory.ts
new file mode 100644
index 000000000..d4ac5b471
--- /dev/null
+++ b/apps/server/src/integrations/outbound/outbound-agent.factory.ts
@@ -0,0 +1,55 @@
+import { Injectable } from '@nestjs/common';
+import { Agent, Dispatcher } from 'undici';
+import { OutboundUrlGuard } from './outbound-url.guard';
+
+export const OUTBOUND_REQUEST_TIMEOUT_MS = 10_000;
+
+export type OutboundTlsOptions = {
+ caCert?: string; // PEM encoded
+ rejectUnauthorized?: boolean; // Defaults to true; self-hosted only when false.
+};
+
+export type AgentLease = {
+ dispatcher: Dispatcher;
+ release: () => Promise;
+};
+
+export type IOutboundAgentFactory = {
+ lease(url: string, tls?: OutboundTlsOptions): Promise;
+};
+
+/** Creates a per-request agent pinned to the address validated by the SSRF guard. */
+@Injectable()
+export class OutboundAgentFactory implements IOutboundAgentFactory {
+ constructor(private readonly urlGuard: OutboundUrlGuard) {}
+
+ async lease(url: string, tls?: OutboundTlsOptions): Promise {
+ const pinned = await this.urlGuard.validate(url);
+
+ const lookup = (_hostname: string, options: any, callback: any) => {
+ if (options?.all) {
+ callback(null, [{ address: pinned.address, family: pinned.family }]);
+ } else {
+ callback(null, pinned.address, pinned.family);
+ }
+ };
+
+ const agent = new Agent({
+ connect: {
+ ca: tls?.caCert || undefined,
+ rejectUnauthorized: tls?.rejectUnauthorized ?? true,
+ lookup: lookup as any,
+ timeout: OUTBOUND_REQUEST_TIMEOUT_MS,
+ },
+ headersTimeout: OUTBOUND_REQUEST_TIMEOUT_MS,
+ bodyTimeout: OUTBOUND_REQUEST_TIMEOUT_MS,
+ });
+
+ return {
+ dispatcher: agent,
+ release: async () => {
+ await agent.close();
+ },
+ };
+ }
+}
diff --git a/apps/server/src/integrations/outbound/outbound-network-policy.spec.ts b/apps/server/src/integrations/outbound/outbound-network-policy.spec.ts
new file mode 100644
index 000000000..b6f8399c8
--- /dev/null
+++ b/apps/server/src/integrations/outbound/outbound-network-policy.spec.ts
@@ -0,0 +1,143 @@
+import {
+ parseOutboundNetworkPolicy,
+ policyNamesAddress,
+} from './outbound-network-policy';
+
+describe('parseOutboundNetworkPolicy', () => {
+ it('parses a bare mode', () => {
+ expect(parseOutboundNetworkPolicy('all')).toMatchObject({
+ mode: 'all',
+ entries: [],
+ invalid: false,
+ });
+ expect(parseOutboundNetworkPolicy('none')).toMatchObject({
+ mode: 'none',
+ entries: [],
+ invalid: false,
+ });
+ });
+
+ it('treats an empty value as none with no entries', () => {
+ for (const raw of ['', ' ', ',,']) {
+ expect(parseOutboundNetworkPolicy(raw)).toMatchObject({
+ mode: 'none',
+ entries: [],
+ invalid: false,
+ });
+ }
+ });
+
+ it('ignores case and surrounding whitespace on the mode', () => {
+ expect(parseOutboundNetworkPolicy(' ALL ')).toMatchObject({
+ mode: 'all',
+ invalid: false,
+ });
+ });
+
+ it('parses a mode followed by entries', () => {
+ const policy = parseOutboundNetworkPolicy('all,127.0.0.0/8,::1/128');
+
+ expect(policy.mode).toBe('all');
+ expect(policy.entries).toHaveLength(2);
+ expect(policyNamesAddress(policy, '127.0.0.1', 80)).toBe(true);
+ expect(policyNamesAddress(policy, '127.0.0.1', 8088)).toBe(true);
+ expect(policyNamesAddress(policy, '::1', 443)).toBe(true);
+ expect(policyNamesAddress(policy, '10.1.2.3', 443)).toBe(false);
+ });
+
+ it('parses an entry with a port and matches only that port', () => {
+ const policy = parseOutboundNetworkPolicy('none,192.168.1.20/32:8088');
+
+ expect(policy.mode).toBe('none');
+ expect(policyNamesAddress(policy, '192.168.1.20', 8088)).toBe(true);
+ expect(policyNamesAddress(policy, '192.168.1.20', 443)).toBe(false);
+ expect(policyNamesAddress(policy, '192.168.1.21', 8088)).toBe(false);
+ });
+
+ it('parses a bracketed IPv6 entry with a port', () => {
+ const policy = parseOutboundNetworkPolicy('[::1/128]:8088');
+
+ expect(policy.mode).toBe('none');
+ expect(policyNamesAddress(policy, '::1', 8088)).toBe(true);
+ expect(policyNamesAddress(policy, '::1', 80)).toBe(false);
+ });
+
+ it('treats entries without a mode as none plus those entries', () => {
+ const policy = parseOutboundNetworkPolicy('10.0.0.0/8');
+
+ expect(policy.mode).toBe('none');
+ expect(policy.invalid).toBe(false);
+ expect(policyNamesAddress(policy, '10.1.2.3', 443)).toBe(true);
+ expect(policyNamesAddress(policy, '192.168.1.1', 443)).toBe(false);
+ });
+
+ it.each([
+ 'not-a-cidr',
+ 'all,not-a-cidr',
+ 'all,10.0.0.0/8,nonsense',
+ '10.0.0.0/33',
+ '10.0.0.0',
+ '::1/129',
+ '::1/128:8088',
+ '10.0.0.0/8:0',
+ '10.0.0.0/8:70000',
+ '[::1/128]:notaport',
+ '0.0.0.0/0',
+ '::/0',
+ 'all,0.0.0.0/0',
+ '[::/0]:8088',
+ '192.168.1.20/24',
+ '10.1.0.0/8',
+ '172.16.0.1/12',
+ 'fc00::1/7',
+ '[::1/127]',
+ '2001:db8::1/32:8088',
+ ])('fails closed on %s', (raw) => {
+ expect(parseOutboundNetworkPolicy(raw)).toMatchObject({
+ mode: 'none',
+ entries: [],
+ invalid: true,
+ });
+ });
+
+ it.each([
+ '10.0.0.0/8',
+ '172.16.0.0/12',
+ '100.64.0.0/10',
+ '192.168.1.20/32',
+ 'fc00::/7',
+ 'fe80::/10',
+ '::1/128',
+ ])('accepts %s, whose address sits on its prefix boundary', (raw) => {
+ expect(parseOutboundNetworkPolicy(raw)).toMatchObject({
+ entries: [expect.anything()],
+ invalid: false,
+ });
+ });
+
+ it('accepts a bracketed IPv6 entry without a port as the unbracketed form', () => {
+ const bracketed = parseOutboundNetworkPolicy('[::1/128]');
+ const bare = parseOutboundNetworkPolicy('::1/128');
+
+ expect(bracketed).toMatchObject({ mode: 'none', invalid: false });
+ for (const port of [80, 443, 8088]) {
+ expect(policyNamesAddress(bracketed, '::1', port)).toBe(
+ policyNamesAddress(bare, '::1', port),
+ );
+ expect(policyNamesAddress(bracketed, '::1', port)).toBe(true);
+ }
+ });
+
+ it('never names an address when the value is unparseable or the address is not an IP', () => {
+ const policy = parseOutboundNetworkPolicy('all,10.0.0.0/8');
+
+ expect(policyNamesAddress(policy, 'siem.internal', 443)).toBe(false);
+ expect(policyNamesAddress(parseOutboundNetworkPolicy('garbage'), '10.1.2.3', 443)).toBe(false);
+ });
+
+ it('matches an IPv4-mapped IPv6 address against an IPv4 entry', () => {
+ const policy = parseOutboundNetworkPolicy('127.0.0.0/8');
+
+ expect(policyNamesAddress(policy, '::ffff:127.0.0.1', 80)).toBe(true);
+ });
+});
diff --git a/apps/server/src/integrations/outbound/outbound-network-policy.ts b/apps/server/src/integrations/outbound/outbound-network-policy.ts
new file mode 100644
index 000000000..11b816530
--- /dev/null
+++ b/apps/server/src/integrations/outbound/outbound-network-policy.ts
@@ -0,0 +1,110 @@
+import { BlockList, isIPv4, isIPv6 } from 'node:net';
+
+export type OutboundPolicyMode = 'all' | 'none';
+
+export type OutboundPolicyEntry = { list: BlockList; port?: number };
+
+/** An invalid policy denies all private destinations. */
+export type OutboundNetworkPolicy = {
+ mode: OutboundPolicyMode;
+ entries: OutboundPolicyEntry[];
+ invalid: boolean;
+};
+
+function toBytes(address: string, family: 'ipv4' | 'ipv6'): number[] {
+ if (family === 'ipv4') return address.split('.').map(Number);
+
+ const bytesOf = (part: string): number[] =>
+ part
+ ? part.split(':').flatMap((group) => {
+ if (group.includes('.')) return group.split('.').map(Number);
+ const value = parseInt(group, 16);
+ return [value >> 8, value & 0xff];
+ })
+ : [];
+
+ const [head, tail] = address.split('::');
+ const headBytes = bytesOf(head);
+ const tailBytes = address.includes('::') ? bytesOf(tail) : [];
+ const zeros = new Array(16 - headBytes.length - tailBytes.length).fill(0);
+ return [...headBytes, ...zeros, ...tailBytes];
+}
+
+function hasHostBits(bytes: number[], prefix: number): boolean {
+ return bytes.some((byte, index) => {
+ const bitsBefore = index * 8;
+ if (bitsBefore >= prefix) return byte !== 0;
+ return (byte & (0xff >> Math.min(8, prefix - bitsBefore))) !== 0;
+ });
+}
+
+/** Prefix zero is reserved for the explicit `all` mode. */
+function parseCidr(
+ raw: string,
+): { address: string; prefix: number; family: 'ipv4' | 'ipv6' } | null {
+ const [address, prefixRaw] = raw.split('/');
+ if (!prefixRaw) return null;
+ const prefix = Number(prefixRaw);
+ if (!Number.isInteger(prefix) || prefix < 1) return null;
+ const family = isIPv4(address) ? 'ipv4' : isIPv6(address) ? 'ipv6' : null;
+ if (!family) return null;
+ if (prefix > (family === 'ipv4' ? 32 : 128)) return null;
+ if (hasHostBits(toBytes(address, family), prefix)) return null;
+ return { address, prefix, family };
+}
+
+/** Parses optional ports without treating IPv6 colons as separators. */
+function splitPort(token: string): { cidr: string; port?: number } {
+ const bracketed = /^\[(.+)\](?::(\d+))?$/.exec(token);
+ if (bracketed) {
+ const [, cidr, port] = bracketed;
+ return port === undefined ? { cidr } : { cidr, port: Number(port) };
+ }
+ const withPort = /^([^:]+):(\d+)$/.exec(token);
+ if (withPort) return { cidr: withPort[1], port: Number(withPort[2]) };
+ return { cidr: token };
+}
+
+function parseEntry(token: string): OutboundPolicyEntry | null {
+ const { cidr: raw, port } = splitPort(token);
+ if (port !== undefined && (port < 1 || port > 65535)) return null;
+ const cidr = parseCidr(raw);
+ if (!cidr) return null;
+ const list = new BlockList();
+ list.addSubnet(cidr.address, cidr.prefix, cidr.family);
+ return { list, port };
+}
+
+/** Parses `[all|none,]CIDR[:port],...` and fails closed on invalid input. */
+export function parseOutboundNetworkPolicy(raw: string): OutboundNetworkPolicy {
+ const tokens = (raw ?? '')
+ .split(',')
+ .map((token) => token.trim())
+ .filter(Boolean);
+ if (tokens.length === 0) return { mode: 'none', entries: [], invalid: false };
+
+ const first = tokens[0].toLowerCase();
+ const hasMode = first === 'all' || first === 'none';
+ const mode: OutboundPolicyMode = hasMode ? first : 'none';
+
+ const entries: OutboundPolicyEntry[] = [];
+ for (const token of hasMode ? tokens.slice(1) : tokens) {
+ const entry = parseEntry(token);
+ if (!entry) return { mode: 'none', entries: [], invalid: true };
+ entries.push(entry);
+ }
+ return { mode, entries, invalid: false };
+}
+
+export function policyNamesAddress(
+ policy: OutboundNetworkPolicy,
+ ip: string,
+ port: number,
+): boolean {
+ const family = isIPv4(ip) ? 'ipv4' : isIPv6(ip) ? 'ipv6' : null;
+ if (!family) return false;
+ return policy.entries.some(
+ (entry) =>
+ (entry.port === undefined || entry.port === port) && entry.list.check(ip, family),
+ );
+}
diff --git a/apps/server/src/integrations/outbound/outbound-url.guard.spec.ts b/apps/server/src/integrations/outbound/outbound-url.guard.spec.ts
new file mode 100644
index 000000000..e480a0928
--- /dev/null
+++ b/apps/server/src/integrations/outbound/outbound-url.guard.spec.ts
@@ -0,0 +1,412 @@
+import { Logger } from '@nestjs/common';
+import {
+ isAlwaysBlockedAddress,
+ isHardBlockedAddress,
+ isPrivateAddress,
+ isPrivateNetworkAddress,
+ OutboundUrlError,
+ OutboundUrlGuard,
+} from './outbound-url.guard';
+
+function guard(
+ isCloud: boolean,
+ addresses: Array<{ address: string; family: number }>,
+ privateNetworks: string = 'none',
+) {
+ return new OutboundUrlGuard(
+ {
+ isCloud: () => isCloud,
+ getAllowedPrivateNetworks: () => privateNetworks,
+ } as any,
+ async () => addresses,
+ );
+}
+
+function family(ip: string): number {
+ return ip.includes(':') ? 6 : 4;
+}
+
+describe('isPrivateAddress', () => {
+ it.each([
+ '127.0.0.1', '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.1',
+ '169.254.169.254', '100.64.0.1', '0.0.0.0', '224.0.0.1',
+ '::1', '::', 'fe80::1', 'fc00::1', 'fd12::1', 'ff02::1', '::ffff:10.0.0.1',
+ '0:0:0:0:0:0:0:1', '::ffff:a00:1', '::ffff:7f00:1', '0000:0000:0000:0000:0000:0000:0000:0000',
+ '192.0.0.1', '192.0.2.1', '192.88.99.1', '198.18.0.1', '198.51.100.7', '203.0.113.5',
+ '::a00:1', '64:ff9b::a00:1', '64:ff9b:1::a00:1', '100::1', '2001::1', '2001:0:a00:1::1', '2001:db8::1', '2002:a00:1::1', 'fec0::1',
+ ])('flags %s as private or reserved', (ip) => {
+ expect(isPrivateAddress(ip)).toBe(true);
+ });
+
+ it.each(['8.8.8.8', '172.32.0.1', '2606:4700::1111', '::ffff:8.8.8.8', '::ffff:5db8:d822', '::ffff:8.8.8.8', '2001:4860:4860::8888', '100.128.0.1', '198.17.255.255'])(
+ 'allows public %s',
+ (ip) => {
+ expect(isPrivateAddress(ip)).toBe(false);
+ },
+ );
+});
+
+describe('isAlwaysBlockedAddress / isPrivateNetworkAddress', () => {
+ it.each([
+ '0.0.0.0', '127.0.0.1', '169.254.169.254', '192.0.0.1', '192.0.2.1',
+ '192.88.99.1', '198.18.0.1', '198.51.100.7', '203.0.113.5', '224.0.0.1',
+ '::1', '::', '::ffff:127.0.0.1', '::ffff:0:7f00:1', '64:ff9b::a00:1', '64:ff9b:1::a00:1',
+ '100::1', '2001::1', '2001:db8::1', '2002:a00:1::1', 'fe80::1', 'fec0::1',
+ 'ff02::1',
+ ])('flags %s as always-blocked but not a private network', (ip) => {
+ expect(isAlwaysBlockedAddress(ip)).toBe(true);
+ expect(isPrivateNetworkAddress(ip)).toBe(false);
+ });
+
+ it.each([
+ '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.1', '100.64.0.1',
+ 'fc00::1', 'fd12::1',
+ ])('flags %s as a private network but not always-blocked', (ip) => {
+ expect(isPrivateNetworkAddress(ip)).toBe(true);
+ expect(isAlwaysBlockedAddress(ip)).toBe(false);
+ });
+
+ it.each(['8.8.8.8', '172.32.0.1', '2606:4700::1111', '100.128.0.1'])(
+ 'allows public %s in both',
+ (ip) => {
+ expect(isAlwaysBlockedAddress(ip)).toBe(false);
+ expect(isPrivateNetworkAddress(ip)).toBe(false);
+ },
+ );
+
+ it('the two lists together are exactly isPrivateAddress', () => {
+ for (const ip of ['10.0.0.5', '127.0.0.1', '8.8.8.8', 'fe80::1', 'fc00::1']) {
+ expect(isAlwaysBlockedAddress(ip) || isPrivateNetworkAddress(ip)).toBe(
+ isPrivateAddress(ip),
+ );
+ }
+ });
+});
+
+describe('OutboundUrlGuard.validate', () => {
+ const publicV4 = { address: '93.184.216.34', family: 4 };
+
+ it('rejects http on cloud', async () => {
+ await expect(guard(true, [publicV4]).validate('http://siem.example.com/x'))
+ .rejects.toThrow(OutboundUrlError);
+ });
+
+ it('rejects hosts that resolve to a private range on cloud', async () => {
+ await expect(
+ guard(true, [publicV4, { address: '10.0.0.5', family: 4 }]).validate('https://siem.example.com'),
+ ).rejects.toThrow(/private or reserved/);
+ });
+
+ it('rejects the cloud metadata address literal', async () => {
+ await expect(guard(true, []).validate('https://169.254.169.254/latest'))
+ .rejects.toThrow(/private or reserved/);
+ });
+
+ it('allows LAN hosts and http on self-hosted when private networks are allowed', async () => {
+ const pinned = await guard(false, [{ address: '10.0.5.20', family: 4 }], 'all')
+ .validate('http://splunk.internal:8088/services/collector/event');
+ expect(pinned).toEqual({ hostname: 'splunk.internal', address: '10.0.5.20', family: 4 });
+ });
+
+ it('pins the first resolved address and keeps the hostname for SNI', async () => {
+ const pinned = await guard(true, [{ address: '2606:4700::1111', family: 6 }, publicV4])
+ .validate('https://siem.example.com');
+ expect(pinned).toEqual({ hostname: 'siem.example.com', address: '2606:4700::1111', family: 6 });
+ });
+
+ it('rejects credentials in the URL and unresolvable hosts', async () => {
+ await expect(guard(false, [publicV4]).validate('https://user:pw@siem.example.com'))
+ .rejects.toThrow(/credentials/);
+ await expect(guard(false, []).validate('https://nope.example.com'))
+ .rejects.toThrow(/Could not resolve/);
+ });
+
+ it('marks resolution failures retryable and configuration failures not', async () => {
+ const throwing = new OutboundUrlGuard(
+ { isCloud: () => false } as any,
+ async () => {
+ throw new Error('EAI_AGAIN');
+ },
+ );
+
+ const dnsError = await throwing
+ .validate('https://siem.example.com')
+ .catch((e) => e);
+ expect(dnsError).toBeInstanceOf(OutboundUrlError);
+ expect(dnsError.retryable).toBe(true);
+
+ const emptyError = await guard(false, [])
+ .validate('https://nope.example.com')
+ .catch((e) => e);
+ expect(emptyError.retryable).toBe(true);
+
+ for (const url of [
+ 'not-a-url',
+ 'ftp://siem.example.com',
+ 'https://user:pw@siem.example.com',
+ ]) {
+ const err = await guard(false, [publicV4])
+ .validate(url)
+ .catch((e) => e);
+ expect(err).toBeInstanceOf(OutboundUrlError);
+ expect(err.retryable).toBe(false);
+ }
+
+ const privateError = await guard(true, [{ address: '10.0.0.5', family: 4 }])
+ .validate('https://siem.example.com')
+ .catch((e) => e);
+ expect(privateError.retryable).toBe(false);
+ });
+
+ const hardBlocked = ['169.254.169.254', '0.0.0.0', 'fe80::1', 'ff02::1'];
+ const loopbackOrReserved = ['127.0.0.1', '::1', '::ffff:127.0.0.1', '192.0.2.1'];
+
+ it.each(hardBlocked)(
+ 'self-hosted refuses %s under every ALLOWED_PRIVATE_NETWORKS value',
+ async (ip) => {
+ for (const value of ['all', 'none', '169.254.0.0/16', 'all,169.254.0.0/16', 'all,fe80::/10']) {
+ await expect(
+ guard(false, [{ address: ip, family: family(ip) }], value).validate(
+ 'http://siem.internal',
+ ),
+ ).rejects.toThrow(/link-local, metadata or reserved address .* which is never allowed/);
+ }
+ },
+ );
+
+ it.each(loopbackOrReserved)(
+ 'self-hosted refuses %s unless an entry names it',
+ async (ip) => {
+ for (const value of ['all', 'none']) {
+ await expect(
+ guard(false, [{ address: ip, family: family(ip) }], value).validate(
+ 'http://siem.internal',
+ ),
+ ).rejects.toThrow(
+ /resolves to a loopback or reserved address .* Set ALLOWED_PRIVATE_NETWORKS on the server to allow it/,
+ );
+ }
+ },
+ );
+
+ it.each(['10.1.2.3', '192.168.1.10'])(
+ 'self-hosted refuses private network %s by default',
+ async (ip) => {
+ await expect(
+ guard(false, [{ address: ip, family: 4 }]).validate('http://siem.internal'),
+ ).rejects.toThrow(
+ /resolves to a private address .* Set ALLOWED_PRIVATE_NETWORKS on the server to allow it/,
+ );
+ },
+ );
+
+ it.each(['10.1.2.3', '192.168.1.10', 'fc00::1', '100.64.0.1'])(
+ 'all accepts private network %s',
+ async (ip) => {
+ const pinned = await guard(
+ false,
+ [{ address: ip, family: family(ip) }],
+ 'all',
+ ).validate('http://siem.internal');
+ expect(pinned.address).toBe(ip);
+ },
+ );
+
+ it('all still refuses loopback, and a loopback entry opts it back in', async () => {
+ await expect(
+ guard(false, [{ address: '127.0.0.1', family: 4 }], 'all').validate(
+ 'http://siem.internal',
+ ),
+ ).rejects.toThrow(/loopback or reserved/);
+
+ const allowed = await guard(
+ false,
+ [{ address: '127.0.0.1', family: 4 }],
+ 'all,127.0.0.0/8',
+ ).validate('http://siem.internal');
+ expect(allowed.address).toBe('127.0.0.1');
+
+ const lan = await guard(
+ false,
+ [{ address: '10.1.2.3', family: 4 }],
+ 'all,127.0.0.0/8',
+ ).validate('http://siem.internal');
+ expect(lan.address).toBe('10.1.2.3');
+
+ await expect(
+ guard(false, [{ address: '::1', family: 6 }], 'all,127.0.0.0/8').validate(
+ 'http://siem.internal',
+ ),
+ ).rejects.toThrow(/loopback or reserved/);
+ });
+
+ it('an entry with a port matches only that port', async () => {
+ const policy = 'none,192.168.1.20/32:8088';
+
+ const allowed = await guard(
+ false,
+ [{ address: '192.168.1.20', family: 4 }],
+ policy,
+ ).validate('https://192.168.1.20:8088/services/collector/event');
+ expect(allowed.address).toBe('192.168.1.20');
+
+ await expect(
+ guard(false, [{ address: '192.168.1.20', family: 4 }], policy).validate(
+ 'https://192.168.1.20',
+ ),
+ ).rejects.toThrow(/private address/);
+
+ await expect(
+ guard(false, [{ address: '192.168.1.21', family: 4 }], policy).validate(
+ 'https://192.168.1.21:8088',
+ ),
+ ).rejects.toThrow(/private address/);
+ });
+
+ it('a bracketed IPv6 entry with a port accepts only that port', async () => {
+ const policy = '[::1/128]:8088';
+
+ const allowed = await guard(
+ false,
+ [{ address: '::1', family: 6 }],
+ policy,
+ ).validate('http://[::1]:8088/ingest');
+ expect(allowed).toEqual({ hostname: '::1', address: '::1', family: 6 });
+
+ await expect(
+ guard(false, [{ address: '::1', family: 6 }], policy).validate('http://[::1]/ingest'),
+ ).rejects.toThrow(/loopback or reserved/);
+ });
+
+ it('an entry without a port matches every port', async () => {
+ for (const url of ['http://127.0.0.1:8088', 'https://127.0.0.1', 'http://127.0.0.1']) {
+ const allowed = await guard(
+ false,
+ [{ address: '127.0.0.1', family: 4 }],
+ '127.0.0.0/8',
+ ).validate(url);
+ expect(allowed.address).toBe('127.0.0.1');
+ }
+ });
+
+ it('entries without a mode none every private network not named', async () => {
+ const allowed = await guard(
+ false,
+ [{ address: '192.168.1.10', family: 4 }],
+ '192.168.1.0/24',
+ ).validate('http://siem.internal');
+ expect(allowed.address).toBe('192.168.1.10');
+
+ await expect(
+ guard(false, [{ address: '10.1.2.3', family: 4 }], '192.168.1.0/24').validate(
+ 'http://siem.internal',
+ ),
+ ).rejects.toThrow(/private address/);
+ });
+
+ it('an unparseable value denies everything private or reserved and logs once per process', async () => {
+ const errorSpy = jest
+ .spyOn(Logger.prototype, 'error')
+ .mockImplementation(() => undefined);
+ const g = guard(false, [{ address: '10.1.2.3', family: 4 }], 'all,10.0.0.0/8, not-a-cidr');
+
+ await expect(g.validate('http://siem.internal')).rejects.toThrow(/private address/);
+ await expect(g.validate('http://siem.internal')).rejects.toThrow(/private address/);
+
+ expect(errorSpy).toHaveBeenCalledTimes(1);
+ expect(errorSpy.mock.calls[0][0]).toMatch(/ALLOWED_PRIVATE_NETWORKS/);
+ errorSpy.mockRestore();
+ });
+
+ it('cloud ignores ALLOWED_PRIVATE_NETWORKS and always refuses private and reserved ranges', async () => {
+ for (const ip of [...hardBlocked, ...loopbackOrReserved, '10.1.2.3', '192.168.1.10']) {
+ for (const value of ['all', 'none', '127.0.0.0/8', 'all,10.0.0.0/8']) {
+ await expect(
+ guard(true, [{ address: ip, family: family(ip) }], value).validate(
+ 'https://siem.example.com',
+ ),
+ ).rejects.toThrow(/private or reserved/);
+ }
+ }
+ });
+
+ it('refuses a resolved address that is not an IP address', async () => {
+ await expect(
+ guard(false, [{ address: 'not-an-ip', family: 4 }], 'all').validate(
+ 'http://siem.internal',
+ ),
+ ).rejects.toThrow(/is not an IP address/);
+ });
+
+ it('refuses the whole host when any one of its addresses is refused', async () => {
+ await expect(
+ guard(
+ false,
+ [publicV4, { address: '10.1.2.3', family: 4 }],
+ 'none',
+ ).validate('http://siem.internal'),
+ ).rejects.toThrow(/private address/);
+
+ await expect(
+ guard(
+ false,
+ [publicV4, { address: '127.0.0.1', family: 4 }],
+ 'all',
+ ).validate('http://siem.internal'),
+ ).rejects.toThrow(/loopback or reserved/);
+
+ await expect(
+ guard(
+ false,
+ [publicV4, { address: '169.254.169.254', family: 4 }],
+ 'all',
+ ).validate('http://siem.internal'),
+ ).rejects.toThrow(/never allowed/);
+ });
+
+ it('refuses an IPv4-translated loopback address even when private networks are allowed', async () => {
+ await expect(
+ guard(false, [{ address: '::ffff:0:7f00:1', family: 6 }], 'all').validate(
+ 'http://siem.internal',
+ ),
+ ).rejects.toThrow(/loopback or reserved/);
+ });
+
+ it('a public address is allowed in every mode', async () => {
+ const errorSpy = jest
+ .spyOn(Logger.prototype, 'error')
+ .mockImplementation(() => undefined);
+
+ for (const value of ['all', 'none', '', '192.168.1.0/24', 'garbage']) {
+ await expect(
+ guard(false, [publicV4], value).validate('http://siem.example.com'),
+ ).resolves.toMatchObject({ address: publicV4.address });
+ }
+ await expect(
+ guard(true, [publicV4], 'all').validate('https://siem.example.com'),
+ ).resolves.toMatchObject({ address: publicV4.address });
+ errorSpy.mockRestore();
+ });
+});
+
+describe('isHardBlockedAddress', () => {
+ it.each([
+ '0.0.0.0', '169.254.169.254', '224.0.0.1', '255.255.255.255',
+ '::', 'fe80::1', 'ff02::1',
+ ])('flags %s as hard-blocked', (ip) => {
+ expect(isHardBlockedAddress(ip)).toBe(true);
+ });
+
+ it.each(['127.0.0.1', '::1', '192.0.2.1', 'fec0::1', '8.8.8.8'])(
+ 'does not flag %s as hard-blocked (it may still be always-blocked)',
+ (ip) => {
+ expect(isHardBlockedAddress(ip)).toBe(false);
+ },
+ );
+
+ it('is a subset of isAlwaysBlockedAddress', () => {
+ for (const ip of ['0.0.0.0', '169.254.169.254', 'fe80::1', 'ff02::1']) {
+ expect(isAlwaysBlockedAddress(ip)).toBe(true);
+ }
+ });
+});
diff --git a/apps/server/src/integrations/outbound/outbound-url.guard.ts b/apps/server/src/integrations/outbound/outbound-url.guard.ts
new file mode 100644
index 000000000..1c9e4a872
--- /dev/null
+++ b/apps/server/src/integrations/outbound/outbound-url.guard.ts
@@ -0,0 +1,231 @@
+import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
+import { promises as dns } from 'node:dns';
+import { BlockList, isIPv4, isIPv6 } from 'node:net';
+import { EnvironmentService } from '../environment/environment.service';
+import {
+ OutboundNetworkPolicy,
+ parseOutboundNetworkPolicy,
+ policyNamesAddress,
+} from './outbound-network-policy';
+
+export const OUTBOUND_LOOKUP = 'OUTBOUND_LOOKUP';
+
+export type ResolvedAddress = { address: string; family: number };
+export type LookupFn = (hostname: string) => Promise;
+export type PinnedAddress = { hostname: string; address: string; family: 4 | 6 };
+
+/** A rejected URL. Only transient resolution failures are retryable. */
+export class OutboundUrlError extends Error {
+ constructor(
+ message: string,
+ readonly retryable: boolean = false,
+ ) {
+ super(message);
+ this.name = 'OutboundUrlError';
+ }
+}
+
+export const defaultLookup: LookupFn = async (hostname) => {
+ const results = await dns.lookup(hostname, { all: true });
+ return results.map((r) => ({ address: r.address, family: r.family }));
+};
+
+// Reserved ranges blocked unless explicitly allowed on self-hosted deployments.
+const ALWAYS_BLOCKED = new BlockList();
+ALWAYS_BLOCKED.addSubnet('0.0.0.0', 8, 'ipv4'); // "this" network / unspecified
+ALWAYS_BLOCKED.addSubnet('127.0.0.0', 8, 'ipv4');
+ALWAYS_BLOCKED.addSubnet('169.254.0.0', 16, 'ipv4'); // link-local / cloud metadata
+ALWAYS_BLOCKED.addSubnet('192.0.0.0', 24, 'ipv4'); // IETF protocol assignments
+ALWAYS_BLOCKED.addSubnet('192.0.2.0', 24, 'ipv4'); // TEST-NET-1
+ALWAYS_BLOCKED.addSubnet('192.88.99.0', 24, 'ipv4'); // deprecated 6to4 relay anycast
+ALWAYS_BLOCKED.addSubnet('198.18.0.0', 15, 'ipv4'); // benchmarking
+ALWAYS_BLOCKED.addSubnet('198.51.100.0', 24, 'ipv4'); // TEST-NET-2
+ALWAYS_BLOCKED.addSubnet('203.0.113.0', 24, 'ipv4'); // TEST-NET-3
+ALWAYS_BLOCKED.addRange('224.0.0.0', '255.255.255.255', 'ipv4'); // multicast + reserved
+ALWAYS_BLOCKED.addSubnet('::', 96, 'ipv6'); // deprecated IPv4-compatible
+ALWAYS_BLOCKED.addSubnet('::ffff:0:0:0', 96, 'ipv6'); // IPv4-translated (SIIT): ::ffff:0:7f00:1 is 127.0.0.1
+ALWAYS_BLOCKED.addSubnet('::', 128, 'ipv6'); // unspecified
+ALWAYS_BLOCKED.addSubnet('::1', 128, 'ipv6'); // loopback
+ALWAYS_BLOCKED.addSubnet('64:ff9b::', 96, 'ipv6'); // NAT64 well-known prefix
+ALWAYS_BLOCKED.addSubnet('64:ff9b:1::', 48, 'ipv6'); // NAT64 local-use
+ALWAYS_BLOCKED.addSubnet('100::', 64, 'ipv6'); // discard-only
+ALWAYS_BLOCKED.addSubnet('2001::', 32, 'ipv6'); // Teredo
+ALWAYS_BLOCKED.addSubnet('2001:db8::', 32, 'ipv6'); // documentation
+ALWAYS_BLOCKED.addSubnet('2002::', 16, 'ipv6'); // 6to4
+ALWAYS_BLOCKED.addSubnet('fe80::', 10, 'ipv6'); // link-local
+ALWAYS_BLOCKED.addSubnet('fec0::', 10, 'ipv6'); // deprecated site-local
+ALWAYS_BLOCKED.addSubnet('ff00::', 8, 'ipv6'); // multicast
+
+// Private ranges that self-hosted deployments can allow.
+const PRIVATE_NETWORKS = new BlockList();
+PRIVATE_NETWORKS.addSubnet('10.0.0.0', 8, 'ipv4');
+PRIVATE_NETWORKS.addSubnet('100.64.0.0', 10, 'ipv4'); // CGNAT
+PRIVATE_NETWORKS.addSubnet('172.16.0.0', 12, 'ipv4');
+PRIVATE_NETWORKS.addSubnet('192.168.0.0', 16, 'ipv4');
+PRIVATE_NETWORKS.addSubnet('fc00::', 7, 'ipv6'); // unique-local
+
+// These ranges cannot be allowed by policy.
+const HARD_BLOCKED = new BlockList();
+HARD_BLOCKED.addSubnet('0.0.0.0', 8, 'ipv4');
+HARD_BLOCKED.addSubnet('169.254.0.0', 16, 'ipv4');
+HARD_BLOCKED.addRange('224.0.0.0', '255.255.255.255', 'ipv4');
+HARD_BLOCKED.addSubnet('::', 128, 'ipv6');
+HARD_BLOCKED.addSubnet('fe80::', 10, 'ipv6');
+HARD_BLOCKED.addSubnet('ff00::', 8, 'ipv6');
+
+/** Returns true for reserved or transition ranges. Invalid input is blocked. */
+export function isAlwaysBlockedAddress(ip: string): boolean {
+ if (isIPv4(ip)) return ALWAYS_BLOCKED.check(ip, 'ipv4');
+ if (isIPv6(ip)) return ALWAYS_BLOCKED.check(ip, 'ipv6');
+ return true;
+}
+
+/** Returns true for ranges that policy cannot allow. Invalid input is blocked. */
+export function isHardBlockedAddress(ip: string): boolean {
+ if (isIPv4(ip)) return HARD_BLOCKED.check(ip, 'ipv4');
+ if (isIPv6(ip)) return HARD_BLOCKED.check(ip, 'ipv6');
+ return true;
+}
+
+/** Returns true for private network ranges. Invalid input is blocked. */
+export function isPrivateNetworkAddress(ip: string): boolean {
+ if (isIPv4(ip)) return PRIVATE_NETWORKS.check(ip, 'ipv4');
+ if (isIPv6(ip)) return PRIVATE_NETWORKS.check(ip, 'ipv6');
+ return true;
+}
+
+/** Returns true for addresses blocked by cloud deployments. */
+export function isPrivateAddress(ip: string): boolean {
+ return isAlwaysBlockedAddress(ip) || isPrivateNetworkAddress(ip);
+}
+
+type Refusal = {
+ address: string;
+ kind: 'not-an-ip' | 'hard-blocked' | 'private' | 'reserved';
+};
+
+function findRefusal(
+ resolved: ResolvedAddress[],
+ port: number,
+ policy: OutboundNetworkPolicy,
+): Refusal | undefined {
+ for (const { address } of resolved) {
+ // Reject invalid resolver output before policy checks.
+ if (!isIPv4(address) && !isIPv6(address)) return { address, kind: 'not-an-ip' };
+ if (isHardBlockedAddress(address)) return { address, kind: 'hard-blocked' };
+ if (policyNamesAddress(policy, address, port)) continue;
+ if (isPrivateNetworkAddress(address)) {
+ if (policy.mode === 'all') continue;
+ return { address, kind: 'private' };
+ }
+ if (isAlwaysBlockedAddress(address)) return { address, kind: 'reserved' };
+ }
+ return undefined;
+}
+
+function describeRefusal(hostname: string, { address, kind }: Refusal): string {
+ if (kind === 'not-an-ip') {
+ return `Destination host "${hostname}" resolved to "${address}", which is not an IP address`;
+ }
+ if (kind === 'hard-blocked') {
+ return `Destination host "${hostname}" resolves to a link-local, metadata or reserved address (${address}), which is never allowed`;
+ }
+ const description =
+ kind === 'private' ? 'a private address' : 'a loopback or reserved address';
+ return `Destination host "${hostname}" resolves to ${description} (${address}). Set ALLOWED_PRIVATE_NETWORKS on the server to allow it`;
+}
+
+function effectivePort(url: URL): number {
+ if (url.port) return Number(url.port);
+ return url.protocol === 'https:' ? 443 : 80;
+}
+
+@Injectable()
+export class OutboundUrlGuard {
+ private readonly logger = new Logger(OutboundUrlGuard.name);
+ private readonly lookup: LookupFn;
+ private cachedPolicy?: { raw: string; policy: OutboundNetworkPolicy };
+
+ constructor(
+ private readonly environmentService: EnvironmentService,
+ @Optional() @Inject(OUTBOUND_LOOKUP) lookup?: LookupFn,
+ ) {
+ this.lookup = lookup ?? defaultLookup;
+ }
+
+ /** Caches the parsed policy and logs each invalid value once. */
+ private resolvePolicy(): OutboundNetworkPolicy {
+ const raw = this.environmentService.getAllowedPrivateNetworks();
+ if (this.cachedPolicy?.raw !== raw) {
+ const policy = parseOutboundNetworkPolicy(raw);
+ if (policy.invalid) {
+ this.logger.error(
+ `Invalid ALLOWED_PRIVATE_NETWORKS value "${raw}"; refusing every private and reserved destination`,
+ );
+ }
+ this.cachedPolicy = { raw, policy };
+ }
+ return this.cachedPolicy.policy;
+ }
+
+ /** Validates the URL and returns the address used to pin the connection. */
+ async validate(rawUrl: string): Promise {
+ let url: URL;
+ try {
+ url = new URL(rawUrl);
+ } catch {
+ throw new OutboundUrlError('Destination URL is not a valid URL');
+ }
+
+ const isCloud = this.environmentService.isCloud();
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
+ throw new OutboundUrlError('Destination URL must use http or https');
+ }
+ if (isCloud && url.protocol !== 'https:') {
+ throw new OutboundUrlError('Destination URL must use https');
+ }
+ if (url.username || url.password) {
+ throw new OutboundUrlError('Destination URL must not contain credentials');
+ }
+
+ const hostname = url.hostname.replace(/^\[|\]$/g, '');
+ let resolved: ResolvedAddress[];
+ if (isIPv4(hostname) || isIPv6(hostname)) {
+ resolved = [{ address: hostname, family: isIPv4(hostname) ? 4 : 6 }];
+ } else {
+ try {
+ resolved = await this.lookup(hostname);
+ } catch {
+ throw new OutboundUrlError(
+ `Could not resolve destination host "${hostname}"`,
+ true,
+ );
+ }
+ }
+ if (resolved.length === 0) {
+ throw new OutboundUrlError(
+ `Could not resolve destination host "${hostname}"`,
+ true,
+ );
+ }
+
+ if (isCloud) {
+ const blocked = resolved.find((r) => isPrivateAddress(r.address));
+ if (blocked) {
+ throw new OutboundUrlError(
+ `Destination host "${hostname}" resolves to a private or reserved address (${blocked.address}), which is not allowed`,
+ );
+ }
+ } else {
+ const refusal = findRefusal(
+ resolved,
+ effectivePort(url),
+ this.resolvePolicy(),
+ );
+ if (refusal) throw new OutboundUrlError(describeRefusal(hostname, refusal));
+ }
+
+ const pick = resolved[0];
+ return { hostname, address: pick.address, family: pick.family === 6 ? 6 : 4 };
+ }
+}
diff --git a/apps/server/src/integrations/outbound/outbound.module.ts b/apps/server/src/integrations/outbound/outbound.module.ts
new file mode 100644
index 000000000..0c0400dff
--- /dev/null
+++ b/apps/server/src/integrations/outbound/outbound.module.ts
@@ -0,0 +1,10 @@
+import { Global, Module } from '@nestjs/common';
+import { OutboundAgentFactory } from './outbound-agent.factory';
+import { OutboundUrlGuard } from './outbound-url.guard';
+
+@Global()
+@Module({
+ providers: [OutboundUrlGuard, OutboundAgentFactory],
+ exports: [OutboundUrlGuard, OutboundAgentFactory],
+})
+export class OutboundModule {}
diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts
index 8b7c03a1f..a03c492d4 100644
--- a/apps/server/src/integrations/queue/constants/queue.constants.ts
+++ b/apps/server/src/integrations/queue/constants/queue.constants.ts
@@ -10,6 +10,7 @@ export enum QueueName {
NOTIFICATION_QUEUE = '{notification-queue}',
AUDIT_QUEUE = '{audit-queue}',
BASE_QUEUE = '{base-queue}',
+ SIEM_QUEUE = '{siem-queue}',
}
export enum QueueJob {
@@ -83,6 +84,9 @@ export enum QueueJob {
AUDIT_LOG = 'audit-log',
AUDIT_CLEANUP = 'audit-cleanup',
+ SIEM_SWEEP = 'siem-sweep',
+ SIEM_DELIVER = 'siem-deliver',
+
PDF_EXPORT_TASK = 'pdf-export-task',
PDF_EXPORT_CLEANUP = 'pdf-export-cleanup',
diff --git a/apps/server/src/integrations/queue/queue.module.ts b/apps/server/src/integrations/queue/queue.module.ts
index 0c2c3c908..77bdf2b41 100644
--- a/apps/server/src/integrations/queue/queue.module.ts
+++ b/apps/server/src/integrations/queue/queue.module.ts
@@ -94,6 +94,14 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
attempts: 3,
},
}),
+ BullModule.registerQueue({
+ name: QueueName.SIEM_QUEUE,
+ defaultJobOptions: {
+ removeOnComplete: true,
+ removeOnFail: true,
+ attempts: 1,
+ },
+ }),
BullModule.registerQueue({
name: QueueName.BASE_QUEUE,
defaultJobOptions: {
diff --git a/apps/server/src/integrations/throttle/throttle.module.ts b/apps/server/src/integrations/throttle/throttle.module.ts
index 4c9537526..9fa3cb6be 100644
--- a/apps/server/src/integrations/throttle/throttle.module.ts
+++ b/apps/server/src/integrations/throttle/throttle.module.ts
@@ -10,6 +10,7 @@ import {
OAUTH_REGISTER_THROTTLER,
OAUTH_TOKEN_THROTTLER,
OAUTH_AUTHORIZE_THROTTLER,
+ SIEM_TEST_THROTTLER,
} from './throttler-names';
import Redis from 'ioredis';
@@ -27,6 +28,7 @@ import Redis from 'ioredis';
{ name: OAUTH_REGISTER_THROTTLER, ttl: 3_600_000, limit: 10 },
{ name: OAUTH_TOKEN_THROTTLER, ttl: 60_000, limit: 60 },
{ name: OAUTH_AUTHORIZE_THROTTLER, ttl: 60_000, limit: 30 },
+ { name: SIEM_TEST_THROTTLER, ttl: 60_000, limit: 10 },
],
errorMessage: 'Too many requests',
storage: new ThrottlerStorageRedisService(
diff --git a/apps/server/src/integrations/throttle/throttler-names.ts b/apps/server/src/integrations/throttle/throttler-names.ts
index 898982976..e0c3b5afa 100644
--- a/apps/server/src/integrations/throttle/throttler-names.ts
+++ b/apps/server/src/integrations/throttle/throttler-names.ts
@@ -3,6 +3,7 @@ export const AI_CHAT_THROTTLER = 'ai-chat';
export const OAUTH_REGISTER_THROTTLER = 'oauth-register';
export const OAUTH_TOKEN_THROTTLER = 'oauth-token';
export const OAUTH_AUTHORIZE_THROTTLER = 'oauth-authorize';
+export const SIEM_TEST_THROTTLER = 'siem-test';
// Every named throttler must appear here; spread it in @SkipThrottle and re-enable per name with false.
export const ALL_NAMED_THROTTLERS_SKIPPED: Record = {
@@ -11,4 +12,5 @@ export const ALL_NAMED_THROTTLERS_SKIPPED: Record = {
[OAUTH_REGISTER_THROTTLER]: true,
[OAUTH_TOKEN_THROTTLER]: true,
[OAUTH_AUTHORIZE_THROTTLER]: true,
+ [SIEM_TEST_THROTTLER]: true,
};
diff --git a/apps/server/src/integrations/transactional/emails/siem-destination-disabled-email.tsx b/apps/server/src/integrations/transactional/emails/siem-destination-disabled-email.tsx
new file mode 100644
index 000000000..8f2fd9834
--- /dev/null
+++ b/apps/server/src/integrations/transactional/emails/siem-destination-disabled-email.tsx
@@ -0,0 +1,41 @@
+import { Section, Text } from 'react-email';
+import * as React from 'react';
+import { content, paragraph } from '../css/styles';
+import { EmailButton, MailBody } from '../partials/partials';
+
+type Props = {
+ destinationName: string;
+ destinationType: string;
+ lastError: string;
+ failingSince: string;
+ settingsLink: string;
+};
+
+export const SiemDestinationDisabledEmail = ({
+ destinationName,
+ destinationType,
+ lastError,
+ failingSince,
+ settingsLink,
+}: Props) => {
+ return (
+
+
+ Hi there,
+
+ Your SIEM destination {destinationName} (
+ {destinationType}) has been failing since {failingSince} and was
+ disabled after 24 hours of failed deliveries.
+
+ Last error: {lastError}
+
+ Your events are kept and delivery resumes from where it stopped when
+ you re-enable it.
+
+
+ View destination
+
+ );
+};
+
+export default SiemDestinationDisabledEmail;
diff --git a/apps/server/src/integrations/transactional/emails/siem-destination-failing-email.tsx b/apps/server/src/integrations/transactional/emails/siem-destination-failing-email.tsx
new file mode 100644
index 000000000..ada3a131d
--- /dev/null
+++ b/apps/server/src/integrations/transactional/emails/siem-destination-failing-email.tsx
@@ -0,0 +1,41 @@
+import { Section, Text } from 'react-email';
+import * as React from 'react';
+import { content, paragraph } from '../css/styles';
+import { EmailButton, MailBody } from '../partials/partials';
+
+type Props = {
+ destinationName: string;
+ destinationType: string;
+ lastError: string;
+ failingSince: string;
+ settingsLink: string;
+};
+
+export const SiemDestinationFailingEmail = ({
+ destinationName,
+ destinationType,
+ lastError,
+ failingSince,
+ settingsLink,
+}: Props) => {
+ return (
+
+
+ Hi there,
+
+ Docmost cannot deliver audit events to your SIEM destination{' '}
+ {destinationName} ({destinationType}).
+
+ Last error: {lastError}
+ Failing since {failingSince}.
+
+ Docmost keeps retrying every 30 minutes. If the destination is still
+ failing 24 hours after it started, it is disabled automatically.
+
+
+ View destination
+
+ );
+};
+
+export default SiemDestinationFailingEmail;
diff --git a/apps/server/src/integrations/transactional/emails/siem-destination-recovered-email.tsx b/apps/server/src/integrations/transactional/emails/siem-destination-recovered-email.tsx
new file mode 100644
index 000000000..8da53ece0
--- /dev/null
+++ b/apps/server/src/integrations/transactional/emails/siem-destination-recovered-email.tsx
@@ -0,0 +1,35 @@
+import { Section, Text } from 'react-email';
+import * as React from 'react';
+import { content, paragraph } from '../css/styles';
+import { EmailButton, MailBody } from '../partials/partials';
+
+type Props = {
+ destinationName: string;
+ destinationType: string;
+ settingsLink: string;
+};
+
+export const SiemDestinationRecoveredEmail = ({
+ destinationName,
+ destinationType,
+ settingsLink,
+}: Props) => {
+ return (
+
+
+ Hi there,
+
+ Your SIEM destination {destinationName} (
+ {destinationType}) is delivering audit events again.
+
+
+ Events buffered during the outage were delivered from where the stream
+ stopped.
+
+
+ View destination
+
+ );
+};
+
+export default SiemDestinationRecoveredEmail;