feat(ee): SIEM

This commit is contained in:
Philipinho
2026-09-04 14:31:43 +01:00
parent 5b85464561
commit c058024685
43 changed files with 2612 additions and 122 deletions
@@ -793,6 +793,10 @@
"Removed page restriction": "Removed page restriction", "Removed page restriction": "Removed page restriction",
"Added page permission": "Added page permission", "Added page permission": "Added page permission",
"Removed page permission": "Removed 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", "day": "day",
"days": "days", "days": "days",
"week": "week", "week": "week",
@@ -880,6 +884,9 @@
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> returned a page for revision", "<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> returned a page for revision",
"Page verification expires soon": "Page verification expires soon", "Page verification expires soon": "Page verification expires soon",
"Page verification has expired": "Page verification has expired", "Page verification has expired": "Page verification has expired",
"SIEM destination <bold>{{name}}</bold> is failing": "SIEM destination <bold>{{name}}</bold> is failing",
"SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures": "SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures",
"SIEM destination <bold>{{name}}</bold> recovered": "SIEM destination <bold>{{name}}</bold> recovered",
"Verifying your email": "Verifying your email", "Verifying your email": "Verifying your email",
"Please wait...": "Please wait...", "Please wait...": "Please wait...",
"Verification failed. The link may have expired.": "Verification failed. The link may have expired.", "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.", "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", "Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
"Title only": "Title only", "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"
} }
+5
View File
@@ -166,6 +166,11 @@ export default function App() {
<Route path={"ai"} element={<AiSettings />} /> <Route path={"ai"} element={<AiSettings />} />
<Route path={"ai/mcp"} element={<AiSettings />} /> <Route path={"ai/mcp"} element={<AiSettings />} />
<Route path={"audit"} element={<AuditLogs />} /> <Route path={"audit"} element={<AuditLogs />} />
<Route path={"audit/siem"} element={<AuditLogs />} />
<Route
path={"siem"}
element={<Navigate to="/settings/audit/siem" replace />}
/>
<Route path={"verifications"} element={<VerifiedPages />} /> <Route path={"verifications"} element={<VerifiedPages />} />
{!isCloud() && <Route path={"license"} element={<License />} />} {!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />} {isCloud() && <Route path={"billing"} element={<Billing />} />}
@@ -118,7 +118,7 @@ const groupedData: DataGroup[] = [
role: "admin", role: "admin",
}, },
{ {
label: "Audit log", label: "Audit logs & SIEM",
icon: IconHistory, icon: IconHistory,
path: "/settings/audit", path: "/settings/audit",
feature: Feature.AUDIT_LOGS, feature: Feature.AUDIT_LOGS,
@@ -219,7 +219,7 @@ export default function SettingsSidebar() {
case "API management": case "API management":
prefetchHandler = prefetchApiKeyManagement; prefetchHandler = prefetchApiKeyManagement;
break; break;
case "Audit log": case "Audit logs & SIEM":
prefetchHandler = prefetchAuditLogs; prefetchHandler = prefetchAuditLogs;
break; break;
case "Verified pages": case "Verified pages":
@@ -22,6 +22,7 @@ export const auditEventLabels: Record<string, string> = {
"user.role_changed": "Changed user role", "user.role_changed": "Changed user role",
"user.password_changed": "Changed password", "user.password_changed": "Changed password",
"user.password_reset": "Reset password", "user.password_reset": "Reset password",
"user.password_reset_requested": "Requested password reset",
"user.updated": "Updated user", "user.updated": "Updated user",
"user.deactivated": "Deactivated user", "user.deactivated": "Deactivated user",
"user.activated": "Activated user", "user.activated": "Activated user",
@@ -62,6 +63,7 @@ export const auditEventLabels: Record<string, string> = {
"page.restriction_removed": "Removed page restriction", "page.restriction_removed": "Removed page restriction",
"page.permission_added": "Added page permission", "page.permission_added": "Added page permission",
"page.permission_removed": "Removed page permission", "page.permission_removed": "Removed page permission",
"page.permission_role_changed": "Changed page permission",
"page.verification_created": "Created page verification", "page.verification_created": "Created page verification",
"page.verification_updated": "Updated page verification", "page.verification_updated": "Updated page verification",
"page.verification_removed": "Removed page verification", "page.verification_removed": "Removed page verification",
@@ -79,6 +81,13 @@ export const auditEventLabels: Record<string, string> = {
"license.activated": "Activated license", "license.activated": "Activated license",
"license.removed": "Removed 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 { export function getEventLabel(event: string): string {
@@ -105,6 +114,10 @@ export const eventFilterOptions: EventGroup[] = [
{ value: "user.activated", label: "Activated user" }, { value: "user.activated", label: "Activated user" },
{ value: "user.role_changed", label: "Changed user role" }, { value: "user.role_changed", label: "Changed user role" },
{ value: "user.password_changed", label: "Changed password" }, { 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_enabled", label: "Enabled MFA" },
{ value: "user.mfa_disabled", label: "Disabled 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.restriction_removed", label: "Removed page restriction" },
{ value: "page.permission_added", label: "Added page permission" }, { value: "page.permission_added", label: "Added page permission" },
{ value: "page.permission_removed", label: "Removed 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_created", label: "Created page verification" },
{ value: "page.verification_updated", label: "Updated page verification" }, { value: "page.verification_updated", label: "Updated page verification" },
{ value: "page.verification_removed", label: "Removed page verification" }, { value: "page.verification_removed", label: "Removed page verification" },
@@ -193,4 +210,19 @@ export const eventFilterOptions: EventGroup[] = [
{ value: "license.removed", label: "Removed license" }, { 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" },
],
},
]; ];
+34 -2
View File
@@ -7,10 +7,12 @@ import {
Popover, Popover,
Select, Select,
Space, Space,
Tabs,
Text, Text,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { IconSettings } from "@tabler/icons-react"; import { IconSettings } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title"; import SettingsTitle from "@/components/settings/settings-title";
import Paginate from "@/components/common/paginate"; import Paginate from "@/components/common/paginate";
@@ -23,6 +25,7 @@ import {
import { IAuditLogParams } from "@/ee/audit/types/audit.types"; import { IAuditLogParams } from "@/ee/audit/types/audit.types";
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels"; import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
import AuditLogsTable from "@/ee/audit/components/audit-logs-table"; 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 useUserRole from "@/hooks/use-user-role";
import { DocumentTitle } from "@/components/ui/document-title.tsx"; import { DocumentTitle } from "@/components/ui/document-title.tsx";
@@ -48,6 +51,8 @@ export default function AuditLogs() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isOwner } = useUserRole(); const { isOwner } = useUserRole();
const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate(); const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate();
const location = useLocation();
const navigate = useNavigate();
const [eventFilter, setEventFilter] = useState<string | null>(null); const [eventFilter, setEventFilter] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
@@ -85,6 +90,8 @@ export default function AuditLogs() {
const { data, isLoading } = useAuditLogsQuery(params); const { data, isLoading } = useAuditLogsQuery(params);
const activeTab = location.pathname.endsWith("/siem") ? "siem" : "audit";
if (!isOwner) { if (!isOwner) {
return null; return null;
} }
@@ -94,12 +101,31 @@ export default function AuditLogs() {
resetCursor(); resetCursor();
}; };
const handleTabChange = (value: string | null) => {
if (value === "siem") {
navigate("/settings/audit/siem");
} else {
navigate("/settings/audit");
}
};
return ( return (
<> <>
<DocumentTitle title={t("Audit log")} /> <DocumentTitle title={t("Audit logs & SIEM")} />
<SettingsTitle title={t("Audit log")} /> <SettingsTitle title={t("Audit logs & SIEM")} />
<Tabs color="dark" value={activeTab} onChange={handleTabChange}>
<Tabs.List>
<Tabs.Tab fw={500} value="audit">
{t("Audit logs")}
</Tabs.Tab>
<Tabs.Tab fw={500} value="siem">
{t("SIEM")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="audit" pt="md">
<Group mb="md" gap="sm"> <Group mb="md" gap="sm">
<Select <Select
placeholder={t("Filter by event")} placeholder={t("Filter by event")}
@@ -213,6 +239,12 @@ export default function AuditLogs() {
onPrev={goPrev} onPrev={goPrev}
/> />
)} )}
</Tabs.Panel>
<Tabs.Panel value="siem" pt="md">
<SiemStreamingPanel />
</Tabs.Panel>
</Tabs>
</> </>
); );
} }
+1
View File
@@ -25,4 +25,5 @@ export const Feature = {
OAUTH: 'oauth', OAUTH: 'oauth',
AI_CONTROLS: 'ai:controls', AI_CONTROLS: 'ai:controls',
MCP_CONTROLS: 'mcp:controls', MCP_CONTROLS: 'mcp:controls',
SIEM: 'siem',
} as const; } as const;
@@ -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 (
<Modal
opened={opened}
onClose={onClose}
title={t("Delete destination")}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<Stack gap="md">
<Text>
{t("Are you sure you want to delete the destination")}{" "}
<strong>{destination?.name}</strong>?
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>{t("Cancel")}</Button>
<Button color="red" onClick={handleDelete} loading={deleteMutation.isPending}>
{t("Delete")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -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<DestinationFormValues>({
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 (
<Modal
opened={opened}
onClose={onClose}
title={isEdit ? t("Edit destination") : t("Add destination")}
size="lg"
closeButtonProps={{ "aria-label": t("Close") }}
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select
label={t("Preset")}
data={[
{ value: "splunk_hec", label: DESTINATION_TYPE_LABELS.splunk_hec },
{ value: "datadog", label: DESTINATION_TYPE_LABELS.datadog },
{ value: "http", label: DESTINATION_TYPE_LABELS.http },
]}
allowDeselect={false}
disabled={isEdit}
{...form.getInputProps("type")}
/>
<TextInput
label={t("Name")}
placeholder={t("e.g. Splunk prod")}
required
data-autofocus
{...form.getInputProps("name")}
/>
{type === "splunk_hec" && (
<>
<TextInput
label={t("HEC URL")}
placeholder="https://splunk.example.com:8088"
required
{...form.getInputProps("url")}
/>
<PasswordInput
label={t("HEC token")}
required={!hasSecrets.token}
{...form.getInputProps("token")}
/>
</>
)}
{type === "datadog" && (
<>
<Select
label={t("Datadog site")}
data={DATADOG_SITES.map((site) => ({ value: site, label: site }))}
allowDeselect={false}
{...form.getInputProps("site")}
/>
<PasswordInput
label={t("API key")}
required={!hasSecrets.apiKey}
{...form.getInputProps("apiKey")}
/>
</>
)}
{type === "http" && (
<>
<TextInput
label={t("Endpoint URL")}
placeholder="https://collector.example.com/docmost"
required
{...form.getInputProps("url")}
/>
<PasswordInput
label={t("Token")}
description={t("Sent in the auth header below. Leave empty if your receiver does not need one.")}
{...form.getInputProps("token")}
/>
</>
)}
<Button
variant="subtle"
size="compact-sm"
onClick={() => setAdvancedOpen((open) => !open)}
style={{ alignSelf: "flex-start" }}
>
{advancedOpen ? t("Hide advanced options") : t("Show advanced options")}
</Button>
<Collapse expanded={advancedOpen}>
<Stack gap="md">
{type === "splunk_hec" && (
<>
<TextInput
label={t("Index")}
description={t("Leave empty to use the token's default index")}
{...form.getInputProps("index")}
/>
<Group grow>
<TextInput label={t("Source")} {...form.getInputProps("source")} />
<TextInput label={t("Sourcetype")} {...form.getInputProps("sourcetype")} />
</Group>
<TextInput
label={t("Host")}
description={t("Defaults to this instance's hostname")}
{...form.getInputProps("host")}
/>
</>
)}
{type === "datadog" && (
<>
<TextInput label={t("Service")} {...form.getInputProps("service")} />
<TextInput label={t("Tags")} placeholder="env:prod,team:security" {...form.getInputProps("tags")} />
</>
)}
{type === "http" && (
<>
<Group grow>
<TextInput label={t("Auth header name")} {...form.getInputProps("authHeaderName")} />
<TextInput label={t("Auth header prefix")} {...form.getInputProps("authHeaderPrefix")} />
</Group>
<Select
label={t("Body format")}
data={[
{ value: "json", label: t("JSON array") },
{ value: "ndjson", label: "NDJSON" },
]}
allowDeselect={false}
{...form.getInputProps("format")}
/>
</>
)}
{showTls && (
<>
{!isCloud() && (
<Switch
label={t("Verify TLS certificate")}
description={
form.values.rejectUnauthorized
? undefined
: t("Insecure: connections can be intercepted.")
}
{...form.getInputProps("rejectUnauthorized", { type: "checkbox" })}
/>
)}
</>
)}
<Switch label={t("Enabled")} {...form.getInputProps("enabled", { type: "checkbox" })} />
</Stack>
</Collapse>
{showTestResult && (
<Alert
color={testState.result.delivered ? "green" : "red"}
icon={testState.result.delivered ? <IconCheck size={16} /> : <IconAlertCircle size={16} />}
>
{testState.result.delivered ? t("Test event delivered successfully.") : testState.result.error}
</Alert>
)}
<Group justify="space-between" mt="md">
<Group gap="sm">
<Button variant="default" onClick={handleTest} loading={testMutation.isPending}>
{t("Test connection")}
</Button>
{requiresTest && (
<Text size="xs" c="dimmed">
{t("Test the connection before saving.")}
</Text>
)}
</Group>
<Group>
<Button variant="default" onClick={onClose}>{t("Cancel")}</Button>
<Button type="submit" disabled={requiresTest} loading={createMutation.isPending || updateMutation.isPending}>
{isEdit ? t("Save") : t("Create")}
</Button>
</Group>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -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 <Badge color="gray" variant="light">{t("Disabled")}</Badge>;
}
if (destination.status === "failing") {
return <Badge color="red" variant="light">{t("Failing")}</Badge>;
}
return <Badge color="green" variant="light">{t("Healthy")}</Badge>;
}
@@ -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<SiemDestinationType, string> = {
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 (
<Table.ScrollContainer minWidth={760}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
<Table.Th>{t("Type")}</Table.Th>
<Table.Th>{t("Enabled")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th>
<Table.Th>{t("Last delivered")}</Table.Th>
<Table.Th>{t("Last error")}</Table.Th>
<Table.Th aria-label={t("Actions")} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{destinations && destinations.length > 0 ? (
destinations.map((destination) => (
<Table.Tr key={destination.id}>
<Table.Td>
<Text fz="sm" fw={500}>{destination.name}</Text>
</Table.Td>
<Table.Td>
<Text fz="sm">{DESTINATION_TYPE_LABELS[destination.type]}</Text>
</Table.Td>
<Table.Td>
<Switch
size="sm"
checked={destination.enabled}
onChange={(event) => onToggle(destination, event.currentTarget.checked)}
aria-label={t("Enabled")}
/>
</Table.Td>
<Table.Td>
<DestinationStatusBadge destination={destination} />
{destination.failingSince &&
(destination.status === "failing" ||
!destination.enabled) && (
<Text
fz="xs"
c="dimmed"
mt={4}
style={{ whiteSpace: "nowrap" }}
>
{t("Failing since {{time}}", {
time: formattedDate(
new Date(destination.failingSince),
),
})}
</Text>
)}
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{destination.lastDeliveredAt
? timeAgo(new Date(destination.lastDeliveredAt))
: t("Never")}
</Text>
</Table.Td>
<Table.Td>
{destination.lastError ? (
<Tooltip label={destination.lastError} multiline w={320}>
<Text fz="xs" c="red" lineClamp={2} style={{ maxWidth: 260 }}>
{destination.lastError}
</Text>
</Tooltip>
) : (
<Text fz="xs" c="dimmed"></Text>
)}
</Table.Td>
<Table.Td>
<Menu shadow="md" width={200}>
<Menu.Target>
<ActionIcon variant="subtle" aria-label={t("Actions")}>
<IconDots size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconEdit size={16} />} onClick={() => onEdit(destination)}>
{t("Edit")}
</Menu.Item>
<Menu.Item leftSection={<IconPlugConnected size={16} />} onClick={() => onTest(destination)}>
{t("Send test event")}
</Menu.Item>
<Menu.Item
leftSection={<IconRefresh size={16} />}
onClick={() => onRetry(destination)}
disabled={!destination.nextAttemptAt}
>
{t("Retry now")}
</Menu.Item>
<Menu.Divider />
<Menu.Item color="red" leftSection={<IconTrash size={16} />} onClick={() => onDelete(destination)}>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
) : (
!isLoading && (
<Table.Tr>
<Table.Td colSpan={7}>
<Text fz="sm" c="dimmed" ta="center" py="md">
{t("No destinations yet")}
</Text>
</Table.Td>
</Table.Tr>
)
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -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<ISiemDestination | null>(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<string, unknown>,
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 && (
<Alert icon={<IconInfoCircle size={16} />} color="yellow" mb="md">
{t("SIEM streaming requires an Enterprise license.")}
</Alert>
)}
<Group justify="flex-end" mb="md">
<Tooltip
label={t("Maximum of {{limit}} destinations reached", {
limit: SIEM_MAX_DESTINATIONS_PER_WORKSPACE,
})}
disabled={!atDestinationLimit}
>
<span>
<Button
onClick={() => {
setSelected(null);
setFormOpened(true);
}}
disabled={!hasFeature || atDestinationLimit}
>
{t("Add destination")}
</Button>
</span>
</Tooltip>
</Group>
{isError && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md">
{t("Could not load SIEM destinations: {{message}}", {
message: extractErrorMessage(error),
})}
</Alert>
)}
{hasFeature && !isError && (
<DestinationTable
destinations={data}
isLoading={isLoading}
onEdit={(destination) => {
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 })
}
/>
)}
<DestinationFormModal
opened={formOpened}
onClose={() => setFormOpened(false)}
destination={selected}
/>
<DeleteDestinationModal
opened={deleteOpened}
onClose={() => setDeleteOpened(false)}
destination={selected}
/>
</>
);
}
@@ -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<string, any>;
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<string, boolean> = {},
): Partial<Record<keyof DestinationFormValues, string>> {
const errors: Partial<Record<keyof DestinationFormValues, string>> = {};
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<string, string> {
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<string, unknown>;
let secrets: Record<string, string>;
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,
};
}
@@ -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<ISiemDestination[], Error> {
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<ISiemDestination, Error, ISiemDestinationInput>({
mutationFn: createSiemDestination,
onSuccess: () => {
notifications.show({ message: t("Destination created") });
invalidate();
},
onError: showError,
});
}
export function useUpdateSiemDestinationMutation() {
const { t } = useTranslation();
const invalidate = useInvalidateDestinations();
return useMutation<ISiemDestination, Error, IUpdateSiemDestinationInput>({
mutationFn: updateSiemDestination,
onSuccess: () => {
notifications.show({ message: t("Destination updated") });
invalidate();
},
onError: showError,
});
}
export function useDeleteSiemDestinationMutation() {
const { t } = useTranslation();
const invalidate = useInvalidateDestinations();
return useMutation<void, Error, { destinationId: string }>({
mutationFn: deleteSiemDestination,
onSuccess: () => {
notifications.show({ message: t("Destination deleted") });
invalidate();
},
onError: showError,
});
}
export function useRetrySiemDestinationMutation() {
const { t } = useTranslation();
const invalidate = useInvalidateDestinations();
return useMutation<void, Error, { destinationId: string }>({
mutationFn: retrySiemDestination,
onSuccess: () => {
notifications.show({ message: t("Retry scheduled") });
invalidate();
},
onError: showError,
});
}
export function useTestSiemDestinationMutation() {
return useMutation<ISiemTestResult, Error, ITestSiemDestinationInput>({
mutationFn: testSiemDestination,
onError: showError,
});
}
@@ -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<ISiemDestination[]> {
const req = await api.post<ISiemDestination[]>("/siem/destinations");
return req.data;
}
export async function createSiemDestination(
data: ISiemDestinationInput,
): Promise<ISiemDestination> {
const req = await api.post<ISiemDestination>("/siem/destinations/create", data);
return req.data;
}
export async function updateSiemDestination(
data: IUpdateSiemDestinationInput,
): Promise<ISiemDestination> {
const req = await api.post<ISiemDestination>("/siem/destinations/update", data);
return req.data;
}
export async function deleteSiemDestination(data: {
destinationId: string;
}): Promise<void> {
await api.post("/siem/destinations/delete", data);
}
export async function testSiemDestination(
data: ITestSiemDestinationInput,
): Promise<ISiemTestResult> {
const req = await api.post<ISiemTestResult>("/siem/destinations/test", data);
return req.data;
}
export async function retrySiemDestination(data: {
destinationId: string;
}): Promise<void> {
await api.post("/siem/destinations/retry", data);
}
@@ -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<string, boolean>;
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<string, unknown>;
secrets?: Record<string, string>;
enabled?: boolean;
}
export interface IUpdateSiemDestinationInput {
destinationId: string;
name?: string;
config?: Record<string, unknown>;
secrets?: Record<string, string>;
enabled?: boolean;
}
export interface ITestSiemDestinationInput {
type: SiemDestinationType;
config: Record<string, unknown>;
secrets?: Record<string, string>;
destinationId?: string;
}
export interface ISiemTestResult {
delivered: boolean;
error?: string;
statusCode?: number;
}
@@ -63,6 +63,12 @@ export function NotificationItem({
return "Page verification expires soon"; return "Page verification expires soon";
case "page.verification_expired": case "page.verification_expired":
return "Page verification has expired"; return "Page verification has expired";
case "siem_destination.failing":
return "SIEM destination <bold>{{name}}</bold> is failing";
case "siem_destination.disabled":
return "SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures";
case "siem_destination.recovered":
return "SIEM destination <bold>{{name}}</bold> recovered";
default: default:
return ""; return "";
} }
@@ -77,6 +83,19 @@ export function NotificationItem({
) )
: undefined; : 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 = () => { const markReadIfNeeded = () => {
if (isUnread) { if (isUnread) {
markRead.mutate([notification.id]); markRead.mutate([notification.id]);
@@ -97,7 +116,7 @@ export function NotificationItem({
return ( return (
<UnstyledButton <UnstyledButton
component={Link} component={Link}
to={pageUrl ?? ""} to={linkUrl ?? ""}
onClick={handleClick} onClick={handleClick}
// auxclick fires for all non-primary buttons; guard to middle-click only (button 1) // auxclick fires for all non-primary buttons; guard to middle-click only (button 1)
// so that right-click (button 2, context menu) does not mark as read // so that right-click (button 2, context menu) does not mark as read
@@ -124,11 +143,21 @@ export function NotificationItem({
<Text size="sm" lineClamp={2}> <Text size="sm" lineClamp={2}>
<Trans <Trans
i18nKey={getNotificationMessageKey()} i18nKey={getNotificationMessageKey()}
values={{ name: notification.actor?.name }} values={{
name: isSiemDestination
? destinationName
: notification.actor?.name,
}}
components={{ bold: <Text span fw={600} /> }} components={{ bold: <Text span fw={600} /> }}
/> />
</Text> </Text>
{lastError && (
<Text size="xs" c="dimmed" lineClamp={1} mt={2}>
{lastError}
</Text>
)}
{notification.page && ( {notification.page && (
<Group gap={4} mt={2} wrap="nowrap"> <Group gap={4} mt={2} wrap="nowrap">
{notification.page.icon ? ( {notification.page.icon ? (
@@ -9,7 +9,10 @@ export type NotificationType =
| "page.verification_expired" | "page.verification_expired"
| "page.verified" | "page.verified"
| "page.approval_requested" | "page.approval_requested"
| "page.approval_rejected"; | "page.approval_rejected"
| "siem_destination.failing"
| "siem_destination.disabled"
| "siem_destination.recovered";
export type INotification = { export type INotification = {
id: string; id: string;
+3 -2
View File
@@ -161,7 +161,8 @@
"moduleFileExtensions": [ "moduleFileExtensions": [
"js", "js",
"json", "json",
"ts" "ts",
"tsx"
], ],
"rootDir": "src", "rootDir": "src",
"testRegex": ".*\\.spec\\.ts$", "testRegex": ".*\\.spec\\.ts$",
@@ -181,7 +182,7 @@
] ]
} }
], ],
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)sx?$": "ts-jest"
}, },
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom)(@|/))" "/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom)(@|/))"
+3 -1
View File
@@ -28,6 +28,7 @@ import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls'; import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module'; import { NoopAuditModule } from './integrations/audit/audit.module';
import { ThrottleModule } from './integrations/throttle/throttle.module'; import { ThrottleModule } from './integrations/throttle/throttle.module';
import { OutboundModule } from './integrations/outbound/outbound.module';
import { EncryptionModule } from './integrations/encryption/encryption.module'; import { EncryptionModule } from './integrations/encryption/encryption.module';
const enterpriseModules = []; const enterpriseModules = [];
@@ -51,7 +52,7 @@ try {
middleware: { mount: true }, middleware: { mount: true },
}), }),
LoggerModule, LoggerModule,
NoopAuditModule, ...(enterpriseModules.length > 0 ? [] : [NoopAuditModule]),
CoreModule, CoreModule,
DatabaseModule, DatabaseModule,
EnvironmentModule, EnvironmentModule,
@@ -98,6 +99,7 @@ try {
SecurityModule, SecurityModule,
TelemetryModule, TelemetryModule,
ThrottleModule, ThrottleModule,
OutboundModule,
...enterpriseModules, ...enterpriseModules,
], ],
controllers: [AppController], controllers: [AppController],
+16 -1
View File
@@ -14,6 +14,7 @@ export const AuditEvent = {
USER_ROLE_CHANGED: 'user.role_changed', USER_ROLE_CHANGED: 'user.role_changed',
USER_PASSWORD_CHANGED: 'user.password_changed', USER_PASSWORD_CHANGED: 'user.password_changed',
USER_PASSWORD_RESET: 'user.password_reset', USER_PASSWORD_RESET: 'user.password_reset',
USER_PASSWORD_RESET_REQUESTED: 'user.password_reset_requested',
USER_UPDATED: 'user.updated', USER_UPDATED: 'user.updated',
USER_DEACTIVATED: 'user.deactivated', USER_DEACTIVATED: 'user.deactivated',
USER_ACTIVATED: 'user.activated', USER_ACTIVATED: 'user.activated',
@@ -69,6 +70,7 @@ export const AuditEvent = {
PAGE_RESTRICTION_REMOVED: 'page.restriction_removed', PAGE_RESTRICTION_REMOVED: 'page.restriction_removed',
PAGE_PERMISSION_ADDED: 'page.permission_added', PAGE_PERMISSION_ADDED: 'page.permission_added',
PAGE_PERMISSION_REMOVED: 'page.permission_removed', PAGE_PERMISSION_REMOVED: 'page.permission_removed',
PAGE_PERMISSION_ROLE_CHANGED: 'page.permission_role_changed',
// Page verification // Page verification
PAGE_VERIFICATION_CREATED: 'page.verification_created', PAGE_VERIFICATION_CREATED: 'page.verification_created',
PAGE_VERIFICATION_UPDATED: 'page.verification_updated', PAGE_VERIFICATION_UPDATED: 'page.verification_updated',
@@ -104,6 +106,16 @@ export const AuditEvent = {
// Attachment // Attachment
ATTACHMENT_UPLOADED: 'attachment.uploaded', ATTACHMENT_UPLOADED: 'attachment.uploaded',
// ATTACHMENT_DELETED: 'attachment.deleted', // 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; } as const;
export type AuditEventType = (typeof AuditEvent)[keyof typeof AuditEvent]; export type AuditEventType = (typeof AuditEvent)[keyof typeof AuditEvent];
@@ -116,7 +128,8 @@ export const EXCLUDED_AUDIT_EVENTS: Set<string> = new Set([
AuditEvent.COMMENT_UPDATED, AuditEvent.COMMENT_UPDATED,
AuditEvent.COMMENT_RESOLVED, AuditEvent.COMMENT_RESOLVED,
AuditEvent.COMMENT_REOPENED, AuditEvent.COMMENT_REOPENED,
AuditEvent.ATTACHMENT_UPLOADED AuditEvent.ATTACHMENT_UPLOADED,
AuditEvent.SIEM_DESTINATION_TEST,
]); ]);
export const AuditResource = { export const AuditResource = {
@@ -136,6 +149,8 @@ export const AuditResource = {
WORKSPACE_INVITATION: 'workspace_invitation', WORKSPACE_INVITATION: 'workspace_invitation',
ATTACHMENT: 'attachment', ATTACHMENT: 'attachment',
LICENSE: 'license', LICENSE: 'license',
SIEM_DESTINATION: 'siem_destination',
TEMPLATE: 'template',
} as const; } as const;
export type AuditResourceType = export type AuditResourceType =
+1
View File
@@ -26,6 +26,7 @@ export const Feature = {
OAUTH: 'oauth', OAUTH: 'oauth',
AI_CONTROLS: 'ai:controls', AI_CONTROLS: 'ai:controls',
MCP_CONTROLS: 'mcp:controls', MCP_CONTROLS: 'mcp:controls',
SIEM: 'siem',
} as const; } as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature]; export type FeatureKey = (typeof Feature)[keyof typeof Feature];
@@ -4,6 +4,7 @@ export const CacheKey = {
`perm:space-roles:${userId}:${spaceId}`, `perm:space-roles:${userId}:${spaceId}`,
PAGE_CAN_EDIT: (userId: string, pageId: string) => PAGE_CAN_EDIT: (userId: string, pageId: string) =>
`perm:can-edit:${userId}:${pageId}`, `perm:can-edit:${userId}:${pageId}`,
SIEM_LICENSED: (workspaceId: string) => `siem:licensed:${workspaceId}`,
}; };
// Permission caches dedupe repeated checks within and across short request bursts. // Permission caches dedupe repeated checks within and across short request bursts.
@@ -219,6 +219,13 @@ export class AuthService {
subject: 'Reset your password', subject: 'Reset your password',
template: emailTemplate, template: emailTemplate,
}); });
this.auditService.log({
event: AuditEvent.USER_PASSWORD_RESET_REQUESTED,
resourceType: AuditResource.USER,
resourceId: user.id,
metadata: { source: 'forgot_password' },
});
} }
async passwordReset( async passwordReset(
@@ -10,6 +10,9 @@ export const NotificationType = {
PAGE_VERIFIED: 'page.verified', PAGE_VERIFIED: 'page.verified',
PAGE_APPROVAL_REQUESTED: 'page.approval_requested', PAGE_APPROVAL_REQUESTED: 'page.approval_requested',
PAGE_APPROVAL_REJECTED: 'page.approval_rejected', 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; } as const;
export type NotificationType = export type NotificationType =
@@ -40,6 +43,9 @@ export const DIRECT_NOTIFICATION_TYPES: NotificationType[] = [
NotificationType.COMMENT_RESOLVED, NotificationType.COMMENT_RESOLVED,
NotificationType.PAGE_USER_MENTION, NotificationType.PAGE_USER_MENTION,
NotificationType.PAGE_PERMISSION_GRANTED, NotificationType.PAGE_PERMISSION_GRANTED,
NotificationType.SIEM_DESTINATION_FAILING,
NotificationType.SIEM_DESTINATION_DISABLED,
NotificationType.SIEM_DESTINATION_RECOVERED,
]; ];
export const UPDATES_NOTIFICATION_TYPES: NotificationType[] = [ export const UPDATES_NOTIFICATION_TYPES: NotificationType[] = [
@@ -0,0 +1,66 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
await db.schema.alterTable('audit').dropColumn('user_agent').execute();
await db.schema.dropTable('siem_destinations').ifExists().execute();
}
+26
View File
@@ -74,6 +74,7 @@ export interface Audit {
resourceId: string | null; resourceId: string | null;
resourceType: string; resourceType: string;
spaceId: string | null; spaceId: string | null;
userAgent: string | null;
workspaceId: string; workspaceId: string;
} }
@@ -361,6 +362,30 @@ export interface SpaceMembers {
userId: string | null; userId: string | null;
} }
export interface SiemDestinations {
config: Json;
consecutiveFailures: Generated<number>;
createdAt: Generated<Timestamp>;
creatorId: string | null;
cursorCreatedAt: Generated<Timestamp>;
cursorId: Generated<string>;
cursorSnapshot: string | null;
enabled: Generated<boolean>;
failingSince: Timestamp | null;
id: Generated<string>;
lastDeliveredAt: Timestamp | null;
lastError: string | null;
lastErrorAt: Timestamp | null;
name: string;
nextAttemptAt: Timestamp | null;
secrets: string;
status: Generated<string>;
type: string;
updatedAt: Generated<Timestamp>;
version: Generated<number>;
workspaceId: string;
}
export interface Spaces { export interface Spaces {
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
creatorId: string | null; creatorId: string | null;
@@ -724,6 +749,7 @@ export interface DB {
pages: Pages; pages: Pages;
scimTokens: ScimTokens; scimTokens: ScimTokens;
shares: Shares; shares: Shares;
siemDestinations: SiemDestinations;
spaceMembers: SpaceMembers; spaceMembers: SpaceMembers;
spaces: Spaces; spaces: Spaces;
templates: Templates; templates: Templates;
@@ -37,6 +37,7 @@ import {
UserSessions, UserSessions,
ApiKeys, ApiKeys,
ScimTokens, ScimTokens,
SiemDestinations,
Watchers, Watchers,
Audit as _Audit, Audit as _Audit,
Templates, Templates,
@@ -267,3 +268,8 @@ export type UpdatableBaseRow = Updateable<Omit<BaseRows, 'id'>>;
export type BaseView = Selectable<BaseViews>; export type BaseView = Selectable<BaseViews>;
export type InsertableBaseView = Insertable<BaseViews>; export type InsertableBaseView = Insertable<BaseViews>;
export type UpdatableBaseView = Updateable<Omit<BaseViews, 'id'>>; export type UpdatableBaseView = Updateable<Omit<BaseViews, 'id'>>;
// SIEM destinations
export type SiemDestination = Selectable<SiemDestinations>;
export type InsertableSiemDestination = Insertable<SiemDestinations>;
export type UpdatableSiemDestination = Updateable<Omit<SiemDestinations, 'id'>>;
@@ -385,4 +385,8 @@ export class EnvironmentService {
.map((o) => o.trim()) .map((o) => o.trim())
.filter(Boolean); .filter(Boolean);
} }
getAllowedPrivateNetworks(): string {
return this.configService.get<string>('ALLOWED_PRIVATE_NETWORKS', 'none');
}
} }
@@ -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);
});
});
@@ -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<void>;
};
export type IOutboundAgentFactory = {
lease(url: string, tls?: OutboundTlsOptions): Promise<AgentLease>;
};
/** 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<AgentLease> {
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();
},
};
}
}
@@ -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);
});
});
@@ -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),
);
}
@@ -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);
}
});
});
@@ -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<ResolvedAddress[]>;
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<PinnedAddress> {
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 };
}
}
@@ -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 {}
@@ -10,6 +10,7 @@ export enum QueueName {
NOTIFICATION_QUEUE = '{notification-queue}', NOTIFICATION_QUEUE = '{notification-queue}',
AUDIT_QUEUE = '{audit-queue}', AUDIT_QUEUE = '{audit-queue}',
BASE_QUEUE = '{base-queue}', BASE_QUEUE = '{base-queue}',
SIEM_QUEUE = '{siem-queue}',
} }
export enum QueueJob { export enum QueueJob {
@@ -83,6 +84,9 @@ export enum QueueJob {
AUDIT_LOG = 'audit-log', AUDIT_LOG = 'audit-log',
AUDIT_CLEANUP = 'audit-cleanup', AUDIT_CLEANUP = 'audit-cleanup',
SIEM_SWEEP = 'siem-sweep',
SIEM_DELIVER = 'siem-deliver',
PDF_EXPORT_TASK = 'pdf-export-task', PDF_EXPORT_TASK = 'pdf-export-task',
PDF_EXPORT_CLEANUP = 'pdf-export-cleanup', PDF_EXPORT_CLEANUP = 'pdf-export-cleanup',
@@ -94,6 +94,14 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
attempts: 3, attempts: 3,
}, },
}), }),
BullModule.registerQueue({
name: QueueName.SIEM_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: true,
attempts: 1,
},
}),
BullModule.registerQueue({ BullModule.registerQueue({
name: QueueName.BASE_QUEUE, name: QueueName.BASE_QUEUE,
defaultJobOptions: { defaultJobOptions: {
@@ -10,6 +10,7 @@ import {
OAUTH_REGISTER_THROTTLER, OAUTH_REGISTER_THROTTLER,
OAUTH_TOKEN_THROTTLER, OAUTH_TOKEN_THROTTLER,
OAUTH_AUTHORIZE_THROTTLER, OAUTH_AUTHORIZE_THROTTLER,
SIEM_TEST_THROTTLER,
} from './throttler-names'; } from './throttler-names';
import Redis from 'ioredis'; import Redis from 'ioredis';
@@ -27,6 +28,7 @@ import Redis from 'ioredis';
{ name: OAUTH_REGISTER_THROTTLER, ttl: 3_600_000, limit: 10 }, { name: OAUTH_REGISTER_THROTTLER, ttl: 3_600_000, limit: 10 },
{ name: OAUTH_TOKEN_THROTTLER, ttl: 60_000, limit: 60 }, { name: OAUTH_TOKEN_THROTTLER, ttl: 60_000, limit: 60 },
{ name: OAUTH_AUTHORIZE_THROTTLER, ttl: 60_000, limit: 30 }, { name: OAUTH_AUTHORIZE_THROTTLER, ttl: 60_000, limit: 30 },
{ name: SIEM_TEST_THROTTLER, ttl: 60_000, limit: 10 },
], ],
errorMessage: 'Too many requests', errorMessage: 'Too many requests',
storage: new ThrottlerStorageRedisService( storage: new ThrottlerStorageRedisService(
@@ -3,6 +3,7 @@ export const AI_CHAT_THROTTLER = 'ai-chat';
export const OAUTH_REGISTER_THROTTLER = 'oauth-register'; export const OAUTH_REGISTER_THROTTLER = 'oauth-register';
export const OAUTH_TOKEN_THROTTLER = 'oauth-token'; export const OAUTH_TOKEN_THROTTLER = 'oauth-token';
export const OAUTH_AUTHORIZE_THROTTLER = 'oauth-authorize'; 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. // Every named throttler must appear here; spread it in @SkipThrottle and re-enable per name with false.
export const ALL_NAMED_THROTTLERS_SKIPPED: Record<string, boolean> = { export const ALL_NAMED_THROTTLERS_SKIPPED: Record<string, boolean> = {
@@ -11,4 +12,5 @@ export const ALL_NAMED_THROTTLERS_SKIPPED: Record<string, boolean> = {
[OAUTH_REGISTER_THROTTLER]: true, [OAUTH_REGISTER_THROTTLER]: true,
[OAUTH_TOKEN_THROTTLER]: true, [OAUTH_TOKEN_THROTTLER]: true,
[OAUTH_AUTHORIZE_THROTTLER]: true, [OAUTH_AUTHORIZE_THROTTLER]: true,
[SIEM_TEST_THROTTLER]: true,
}; };
@@ -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 (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>
Your SIEM destination <strong>{destinationName}</strong> (
{destinationType}) has been failing since {failingSince} and was
disabled after 24 hours of failed deliveries.
</Text>
<Text style={paragraph}>Last error: {lastError}</Text>
<Text style={paragraph}>
Your events are kept and delivery resumes from where it stopped when
you re-enable it.
</Text>
</Section>
<EmailButton href={settingsLink}>View destination</EmailButton>
</MailBody>
);
};
export default SiemDestinationDisabledEmail;
@@ -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 (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>
Docmost cannot deliver audit events to your SIEM destination{' '}
<strong>{destinationName}</strong> ({destinationType}).
</Text>
<Text style={paragraph}>Last error: {lastError}</Text>
<Text style={paragraph}>Failing since {failingSince}.</Text>
<Text style={paragraph}>
Docmost keeps retrying every 30 minutes. If the destination is still
failing 24 hours after it started, it is disabled automatically.
</Text>
</Section>
<EmailButton href={settingsLink}>View destination</EmailButton>
</MailBody>
);
};
export default SiemDestinationFailingEmail;
@@ -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 (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi there,</Text>
<Text style={paragraph}>
Your SIEM destination <strong>{destinationName}</strong> (
{destinationType}) is delivering audit events again.
</Text>
<Text style={paragraph}>
Events buffered during the outage were delivered from where the stream
stopped.
</Text>
</Section>
<EmailButton href={settingsLink}>View destination</EmailButton>
</MailBody>
);
};
export default SiemDestinationRecoveredEmail;