Skip to content

Commit c5c1a1e

Browse files
authored
feat(data-drains): add Microsoft Sentinel ingestion (#9162)
* feat(data-drains): add Microsoft Sentinel ingestion * fix(data-drains): trim pasted Sentinel client secrets
1 parent 96555d3 commit c5c1a1e

15 files changed

Lines changed: 32221 additions & 2 deletions

File tree

‎apps/docs/content/docs/platform/enterprise/data-drains.mdx‎

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: Data drains
3-
description: Export retained logs and Chat data to storage, a warehouse, Datadog, or a webhook endpoint.
3+
description: Export retained logs and Chat data to storage, a warehouse, Microsoft Sentinel, Datadog, or a webhook endpoint.
44
---
55

66
import { Callout } from 'fumadocs-ui/components/callout'
@@ -21,7 +21,7 @@ The detail page shows the last ten runs with status, trigger, time, row count, b
2121

2222
## Sources and row format
2323

24-
Create separate drains for different sources. Every delivered row is JSON-safe NDJSON: one JSON object per line, with an `id` for deduplication.
24+
Create separate drains for different sources. Source rows are JSON-safe NDJSON: one JSON object per line, with an `id` for deduplication. Destinations such as Sentinel convert these rows to their required ingestion format.
2525

2626
| UI source | Wire value | Records |
2727
| --- | --- | --- |
@@ -72,6 +72,86 @@ Supply **Account name** (3–24 lowercase characters), an existing **Container**
7272

7373
**Endpoint suffix** supports `blob.core.usgovcloudapi.net` for US Gov and `blob.core.chinacloudapi.cn` for China. Sim also accepts the legacy Germany suffix `blob.core.cloudapi.de`; verify that the particular account still uses that endpoint before selecting it.
7474

75+
### Microsoft Sentinel
76+
77+
Sends JSON arrays through the Azure Monitor Logs Ingestion API into a custom Log Analytics table in your Sentinel workspace. All five drain sources use the same envelope. This destination supports **Azure public cloud with Microsoft Entra client-secret authentication**; it does not use the legacy workspace shared-key Data Collector API.
78+
79+
#### Prepare Azure
80+
81+
1. Create a custom table, for example `SimLogs_CL`, in the Log Analytics workspace used by Microsoft Sentinel. Use the **Analytics** table plan for Sentinel analytics rules.
82+
2. Create a data collection rule (DCR) with an input stream such as `Custom-SimLogs`. Declare the columns below in both the input stream and the destination table. Route the stream to that workspace/table, with `transformKql: "source"` and `outputStream: "Custom-SimLogs_CL"`.
83+
3. Copy the DCR's **immutable ID** (`dcr-…`) and **logs ingestion endpoint**. A DCR with direct ingestion can provide its own endpoint. For Azure Private Link, use the linked data collection endpoint (DCE); self-hosted Sim must have network access and an outbound policy permitting the resolved private address.
84+
4. Register a Microsoft Entra application, create a client secret, and assign its service principal **Monitoring Metrics Publisher** on the DCR. Copy the tenant ID, application client ID, and secret **value**. Role assignment propagation can take up to 30 minutes.
85+
86+
See Microsoft's [Logs Ingestion API setup](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/tutorial-logs-ingestion-portal) and [DCR routing requirements](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/logs-ingestion-api-overview). Sim does not provision these Azure resources.
87+
88+
Use this input stream declaration:
89+
90+
```json
91+
{
92+
"Custom-SimLogs": {
93+
"columns": [
94+
{ "name": "TimeGenerated", "type": "datetime" },
95+
{ "name": "Source", "type": "string" },
96+
{ "name": "RecordId", "type": "string" },
97+
{ "name": "DrainId", "type": "string" },
98+
{ "name": "RunId", "type": "string" },
99+
{ "name": "Data", "type": "dynamic" }
100+
]
101+
}
102+
}
103+
```
104+
105+
#### Configure and verify the drain
106+
107+
Choose **Microsoft Sentinel**, then supply **Logs ingestion endpoint**, **DCR immutable ID**, **Stream name**, **Tenant ID**, **Client ID**, and **Client secret**. Enter only the endpoint origin, such as `https://my-dcr.eastus-1.ingest.monitor.azure.com`; Sim appends the DCR path and API version. Endpoints must use HTTPS on the Azure public-cloud ingestion domain. Sim validates DNS, pins the resolved address and refuses redirects.
108+
109+
Sim obtains an access token from the tenant's `/oauth2/v2.0/token` endpoint using `client_credentials` and scope `https://monitor.azure.com//.default`. The client secret uses the existing encrypted drain-credential storage; access tokens stay in memory for the run and are refreshed before expiry.
110+
111+
**Test connection writes one probe event** with `Source: "connection_test"` and `Data: {"sim":"connection-test"}`. Successful testing confirms Azure accepted the request, not that a row is already queryable or a Sentinel rule fired. Run the drain and verify receipt in Log Analytics; initial ingestion can take several minutes.
112+
113+
An audit event is delivered as:
114+
115+
```json
116+
[
117+
{
118+
"TimeGenerated": "2026-10-16T12:00:00.000Z",
119+
"Source": "audit_logs",
120+
"RecordId": "audit-event-123",
121+
"DrainId": "drain-123",
122+
"RunId": "run-123",
123+
"Data": {
124+
"id": "audit-event-123",
125+
"createdAt": "2026-10-15T09:00:00.000Z",
126+
"action": "workflow.updated",
127+
"actorId": "user-123"
128+
}
129+
}
130+
]
131+
```
132+
133+
`TimeGenerated` is the **drain run start**, allowing retained-history backfills to be ingested without changing the original event. Source timestamps remain in `Data.createdAt`, `Data.startedAt`, `Data.endedAt`, or `Data.completedAt`. Use the appropriate original timestamp for security correlation; ingestion time alone does not establish when an action happened. The destination preserves the full original row under `Data` and does not automatically create Sentinel analytics rules or map entities to ASIM schemas.
134+
135+
For audit events, query and deduplicate by source and record ID:
136+
137+
```kusto
138+
SimLogs_CL
139+
| where Source == "audit_logs"
140+
| summarize arg_max(TimeGenerated, *) by Source, RecordId
141+
| extend EventTime = todatetime(Data.createdAt),
142+
ActorId = tostring(Data.actorId),
143+
Action = tostring(Data.action)
144+
| order by EventTime desc
145+
```
146+
147+
#### Limits and failures
148+
149+
Sim splits JSON arrays at **1,000,000 UTF-8 bytes** and checkpoints each accepted batch before sending the next. Azure can truncate field values above 64 KB, so Sim conservatively rejects a source row whose serialized `Data` exceeds **64,000 UTF-8 bytes**, before sending that chunk. It never truncates the row to fit. Large Chat transcripts and workflow outputs may need an object-storage drain instead. See [Azure ingestion limits](https://learn.microsoft.com/en-us/azure/azure-monitor/fundamentals/service-limits#logs-ingestion-api).
150+
151+
Network failures, `408`, `429`, and `5xx` receive up to three retries after the initial attempt. Sim respects `Retry-After` up to 60 seconds; a longer delay fails the run for later retry rather than sending early. Invalid schema/stream (`400`) and missing DCR permissions (`403`) fail immediately. An ingestion `401` refreshes authentication once. Errors omit provider response bodies and token values.
152+
153+
Delivery remains **at least once**. Azure acceptance followed by a lost response or failed checkpoint can replay rows; successful earlier batches remain checkpointed when a later batch fails. Deduplicate using `Source` and `RecordId`, including across recreated drains. The standard hourly/daily cadence and five-minute source stability window still apply; this is not real-time streaming.
154+
75155
### Google BigQuery
76156

77157
Supply **Project ID**, **Dataset ID**, **Table ID**, and a **Service account JSON key**. Domain-scoped project IDs such as `example.com:my-project` are accepted. The table must already exist with compatible columns for the source's top-level fields; rows are not wrapped in one JSON or STRING column. Sim sets `ignoreUnknownValues: false`.
@@ -189,6 +269,8 @@ curl --fail-with-body \
189269

190270
Trigger.dev is optional. The database backend executes the registered runner with at most two active drain jobs per app process; Trigger.dev uses serialized per-drain jobs. Database claims apply in both modes. Verify scheduler calls and actual destination receipt after enabling the feature.
191271

272+
When upgrading to add the Sentinel destination, apply the additive database migration and deploy the updated app **and all drain workers** before creating Sentinel drains. Older versions do not recognize the new destination type, so creating them during a mixed-version rollout is unsupported.
273+
192274
## Related docs
193275

194276
- [Data retention](/platform/enterprise/data-retention)

‎apps/sim/ee/data-drains/destinations/registry.tsx‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,77 @@ interface DatadogState {
270270
apiKey: string
271271
}
272272

273+
interface SentinelState {
274+
endpoint: string
275+
ruleId: string
276+
streamName: string
277+
tenantId: string
278+
clientId: string
279+
clientSecret: string
280+
}
281+
282+
const SENTINEL_FIELDS = [
283+
{
284+
key: 'endpoint',
285+
label: 'Logs ingestion endpoint',
286+
placeholder: 'https://my-dcr.eastus-1.ingest.monitor.azure.com',
287+
},
288+
{ key: 'ruleId', label: 'DCR immutable ID', placeholder: 'dcr-0123456789abcdef0123456789abcdef' },
289+
{ key: 'streamName', label: 'Stream name', placeholder: 'Custom-SimLogs' },
290+
{ key: 'tenantId', label: 'Tenant ID', placeholder: 'Microsoft Entra tenant ID' },
291+
{ key: 'clientId', label: 'Client ID', placeholder: 'Application client ID' },
292+
] as const
293+
294+
const sentinelFormSpec: DestinationFormSpec<SentinelState> = {
295+
displayName: 'Microsoft Sentinel',
296+
initialState: {
297+
endpoint: '',
298+
ruleId: '',
299+
streamName: 'Custom-SimLogs',
300+
tenantId: '',
301+
clientId: '',
302+
clientSecret: '',
303+
},
304+
FormFields: ({ state, setState }) => (
305+
<>
306+
{SENTINEL_FIELDS.map(({ key, label, placeholder }) => (
307+
<SettingRow key={key} label={label} htmlFor={`drain-sentinel-${key}`}>
308+
<ChipInput
309+
id={`drain-sentinel-${key}`}
310+
value={state[key]}
311+
onChange={(e) => setState({ ...state, [key]: e.target.value })}
312+
placeholder={placeholder}
313+
/>
314+
</SettingRow>
315+
))}
316+
<SettingRow label='Client secret' htmlFor='drain-sentinel-client-secret'>
317+
<SecretInput
318+
id='drain-sentinel-client-secret'
319+
value={state.clientSecret}
320+
onChange={(clientSecret) => setState({ ...state, clientSecret })}
321+
placeholder='Paste the client secret value'
322+
/>
323+
</SettingRow>
324+
<p className='text-[var(--text-muted)] text-caption'>
325+
Use an Azure public-cloud DCR with the Sim log schema. Test connection writes a probe event
326+
to the configured table.
327+
</p>
328+
</>
329+
),
330+
isComplete: (state) => Object.values(state).every((value) => value.trim().length > 0),
331+
toDestinationBranch: ({ clientSecret, ...config }) => ({
332+
destinationType: 'sentinel',
333+
destinationConfig: {
334+
endpoint: config.endpoint.trim(),
335+
ruleId: config.ruleId.trim(),
336+
streamName: config.streamName.trim(),
337+
tenantId: config.tenantId.trim(),
338+
clientId: config.clientId.trim(),
339+
},
340+
destinationCredentials: { clientSecret: clientSecret.trim() },
341+
}),
342+
}
343+
273344
const datadogFormSpec: DestinationFormSpec<DatadogState> = {
274345
displayName: 'Datadog',
275346
initialState: { site: 'us1', service: '', tags: '', apiKey: '' },
@@ -588,6 +659,7 @@ export const DESTINATION_FORM_REGISTRY: Record<DestinationType, DestinationFormS
588659
s3: s3FormSpec as DestinationFormSpec<unknown>,
589660
gcs: gcsFormSpec as DestinationFormSpec<unknown>,
590661
azure_blob: azureBlobFormSpec as DestinationFormSpec<unknown>,
662+
sentinel: sentinelFormSpec as DestinationFormSpec<unknown>,
591663
datadog: datadogFormSpec as DestinationFormSpec<unknown>,
592664
bigquery: bigqueryFormSpec as DestinationFormSpec<unknown>,
593665
snowflake: snowflakeFormSpec as DestinationFormSpec<unknown>,

‎apps/sim/ee/data-drains/labels.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const DESTINATION_LABELS: Record<(typeof DESTINATION_TYPES)[number], stri
1212
s3: 'Amazon S3',
1313
gcs: 'Google Cloud Storage',
1414
azure_blob: 'Azure Blob Storage',
15+
sentinel: 'Microsoft Sentinel',
1516
datadog: 'Datadog',
1617
bigquery: 'Google BigQuery',
1718
snowflake: 'Snowflake',

‎apps/sim/lib/api/contracts/data-drains.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from 'zod'
22
import { defineRouteContract } from '@/lib/api/contracts/types'
3+
import { sentinelConfigSchema } from '@/lib/data-drains/destinations/sentinel-config'
34
import {
45
azureBlobConfigBodySchema,
56
bigqueryConfigBodySchema,
@@ -32,6 +33,10 @@ export {
3233
} from '@/lib/data-drains/validation'
3334

3435
const drainDestinationResponseSchema = z.discriminatedUnion('destinationType', [
36+
z.object({
37+
destinationType: z.literal('sentinel'),
38+
destinationConfig: sentinelConfigSchema,
39+
}),
3540
z.object({
3641
destinationType: z.literal('s3'),
3742
destinationConfig: s3ConfigBodySchema,

‎apps/sim/lib/data-drains/destinations/registry.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { bigqueryDestination } from '@/lib/data-drains/destinations/bigquery'
33
import { datadogDestination } from '@/lib/data-drains/destinations/datadog'
44
import { gcsDestination } from '@/lib/data-drains/destinations/gcs'
55
import { s3Destination } from '@/lib/data-drains/destinations/s3'
6+
import { sentinelDestination } from '@/lib/data-drains/destinations/sentinel'
67
import { snowflakeDestination } from '@/lib/data-drains/destinations/snowflake'
78
import { webhookDestination } from '@/lib/data-drains/destinations/webhook'
89
import type { DestinationType, DrainDestination } from '@/lib/data-drains/types'
@@ -11,6 +12,7 @@ export const DESTINATION_REGISTRY = {
1112
s3: s3Destination,
1213
gcs: gcsDestination,
1314
azure_blob: azureBlobDestination,
15+
sentinel: sentinelDestination,
1416
datadog: datadogDestination,
1517
bigquery: bigqueryDestination,
1618
snowflake: snowflakeDestination,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { z } from 'zod'
2+
3+
const AZURE_INGESTION_HOST = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+ingest\.monitor\.azure\.com$/
4+
const AZURE_DIRECTORY_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
5+
6+
export const sentinelConfigSchema = z.object({
7+
endpoint: z
8+
.string()
9+
.url()
10+
.max(2048)
11+
.refine((value) => {
12+
try {
13+
const url = new URL(value)
14+
return (
15+
url.protocol === 'https:' &&
16+
AZURE_INGESTION_HOST.test(url.hostname) &&
17+
!url.port &&
18+
!url.username &&
19+
!url.password &&
20+
url.pathname === '/' &&
21+
!url.search &&
22+
!url.hash
23+
)
24+
} catch {
25+
return false
26+
}
27+
}, 'endpoint must be an Azure public-cloud HTTPS logs ingestion endpoint without a path, credentials, or query'),
28+
ruleId: z.string().regex(/^dcr-[0-9a-f]{32}$/i, 'ruleId must be the DCR immutable ID'),
29+
streamName: z
30+
.string()
31+
.min(8)
32+
.max(256)
33+
.regex(/^Custom-[A-Za-z0-9_-]+$/, 'streamName must be a Custom- stream declared in the DCR'),
34+
tenantId: z.string().regex(AZURE_DIRECTORY_ID, 'tenantId must be the Microsoft Entra tenant ID'),
35+
clientId: z.string().regex(AZURE_DIRECTORY_ID, 'clientId must be the application client ID'),
36+
})
37+
38+
export const sentinelCredentialsSchema = z.object({
39+
clientSecret: z.string().min(1, 'clientSecret is required').max(4096),
40+
})

0 commit comments

Comments
 (0)