# SIEM Streaming

Source: https://www.orthalon.com/it/docs/operations/siem-streaming

Stream Orthalon decision records and sensor coverage changes to Microsoft Sentinel, Splunk or a signed webhook, with sample detections and a triage playbook.

> **Plan requirement:** Business and Enterprise. Organization owners, admins and security admins manage destinations under **Operations → SIEM streaming**.

Orthalon streams every browser, desktop, gateway, MCP and mobile event to up to five destinations, normally within a minute of the event being stored. Each record is the [decision record](/docs/operations/decision-record): what was attempted, what policy decided, whether enforcement was enforced, confirmed, attempted or failed, what the sensor could see and who acted. Detected values travel only as placeholders. Session titles, justification notes, prompt evidence and file contents are never streamed.

Sensor coverage changes are streamed too: a browser sensor uninstalled, stale, missing permissions or enforcement, not covering private windows, or recovered.

## How delivery works

- **Event-driven, in order, at least once.** Storing events wakes delivery; there is no polling. Each destination keeps a cursor over stored events, so a destination that was down resumes where it stopped.
- **Batching.** Up to 100 events per pass step, split into requests under 1 MB.
- **Retries.** A busy or unreachable receiver is retried after 30 seconds, doubling to at most an hour, with ±20 % jitter and never sooner than its `Retry-After`. Nothing is dropped while it waits.
- **Credentials.** A refused credential (for example an invalid HEC token or Entra secret) holds the stream with the reason shown in the console. Replacing the credential resumes delivery from the same place, so a rotation loses nothing. Credentials are encrypted at rest, never shown again, and never written to logs or error messages.
- **Dead letters.** A record the receiver refuses as malformed is retried on its own; if it is still refused it is kept by id (never its content) for the retention you choose, and the stream moves on. **Details → Replay** sends them again once the receiver is fixed. If a receiver refuses every record, Orthalon treats it as a configuration problem and holds the stream rather than dead-lettering everything.
- **Duplicates.** A retry, an offline sensor resending its queue, or a replay can deliver a record twice. Every delivery of the same event carries the same `dedupKey`; drop repeats on it. The sample searches below do.
- **Health.** Each destination shows p50 and p95 delivery delay (from storage to acceptance by the receiver), the number of events waiting behind its cursor and the age of the oldest, and how many events the receiver refused.

## Payload formats and field suppression

| Format | Webhook | Splunk sourcetype | Sentinel |
| :--- | :--- | :--- | :--- |
| Orthalon decision record v1 (default) | `events[]` | `orthalon:ai_event` | `OrthalonAIEvents_CL` |
| OCSF 1.9.0 Data Security Finding (2006) | `events[]` | `orthalon:ocsf:data_security_finding` | — |
| Sensor coverage change | `events[]` | `orthalon:sensor_coverage` (OCSF: `orthalon:ocsf:device_config_state_change`, class 5019) | `OrthalonSensorCoverage_CL` |

Under **Details → Fields this destination never receives** you can keep the user's email, the actor pseudonym, the account domain, the placeholder excerpt, file metadata, the coaching reason, policy identifiers, sensor identifiers or detector confidence out of one destination. Suppressed fields arrive as `null`. Identity, timestamps, the decision and its outcome cannot be suppressed.

## Microsoft Sentinel

Orthalon sends to Sentinel through the Azure Monitor **Logs Ingestion API**, with a data collection rule (DCR) and a Microsoft Entra application. The older HTTP Data Collector API is deprecated: Microsoft ended support on 14 September 2026 and directs customers to the Logs Ingestion API, so Orthalon does not use it.

1. **Register an application** in Microsoft Entra ID and create a client secret. Note the directory (tenant) ID, application (client) ID and secret value.
2. **Create the tables** in the Log Analytics workspace behind Sentinel, for example with `az rest --method put --url "https://management.azure.com/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.OperationalInsights/workspaces/<workspace>/tables/OrthalonAIEvents_CL?api-version=2022-10-01" --body @table.json`, using:

```json
{
  "properties": {
    "schema": {
      "name": "OrthalonAIEvents_CL",
      "columns": [
        { "name": "TimeGenerated", "type": "dateTime" },
        { "name": "EventId", "type": "string" },
        { "name": "DedupKey", "type": "string" },
        { "name": "SchemaVersion", "type": "string" },
        { "name": "OrthalonTenantId", "type": "string" },
        { "name": "ObservedAt", "type": "dateTime" },
        { "name": "ReceivedAt", "type": "dateTime" },
        { "name": "Surface", "type": "string" },
        { "name": "SensorId", "type": "string" },
        { "name": "SensorVersion", "type": "string" },
        { "name": "ActorPseudonym", "type": "string" },
        { "name": "UserEmail", "type": "string" },
        { "name": "ActorOrigin", "type": "string" },
        { "name": "ActorOriginConfidence", "type": "real" },
        { "name": "ActorOriginEvidence", "type": "string" },
        { "name": "AgenticClient", "type": "string" },
        { "name": "AccountClass", "type": "string" },
        { "name": "AccountSource", "type": "string" },
        { "name": "AccountConfidence", "type": "real" },
        { "name": "AccountReason", "type": "string" },
        { "name": "BrowserContext", "type": "string" },
        { "name": "AccountDomain", "type": "string" },
        { "name": "AppDomain", "type": "string" },
        { "name": "ActionType", "type": "string" },
        { "name": "Direction", "type": "string" },
        { "name": "Decision", "type": "string" },
        { "name": "EnforcementOutcome", "type": "string" },
        { "name": "FailMode", "type": "string" },
        { "name": "FailModeReason", "type": "string" },
        { "name": "CoverageState", "type": "string" },
        { "name": "CoverageReason", "type": "string" },
        { "name": "Severity", "type": "string" },
        { "name": "PolicyId", "type": "string" },
        { "name": "PolicyRuleId", "type": "string" },
        { "name": "PolicyRevision", "type": "string" },
        { "name": "DetectorCategories", "type": "dynamic" },
        { "name": "Detections", "type": "dynamic" },
        { "name": "DetectorCount", "type": "int" },
        { "name": "RedactedExcerpt", "type": "string" },
        { "name": "File", "type": "dynamic" },
        { "name": "FalsePositiveReported", "type": "boolean" },
        { "name": "JustificationReason", "type": "string" },
        { "name": "IsTest", "type": "boolean" }
      ]
    }
  }
}
```

   For coverage changes, create `OrthalonSensorCoverage_CL` the same way:

```json
{
  "properties": {
    "schema": {
      "name": "OrthalonSensorCoverage_CL",
      "columns": [
        { "name": "TimeGenerated", "type": "dateTime" },
        { "name": "EventId", "type": "string" },
        { "name": "DedupKey", "type": "string" },
        { "name": "SchemaVersion", "type": "string" },
        { "name": "OrthalonTenantId", "type": "string" },
        { "name": "ObservedAt", "type": "dateTime" },
        { "name": "SensorId", "type": "string" },
        { "name": "InstallId", "type": "string" },
        { "name": "Browser", "type": "string" },
        { "name": "SensorVersion", "type": "string" },
        { "name": "CoverageState", "type": "string" },
        { "name": "PreviousCoverageState", "type": "string" },
        { "name": "CoverageIssues", "type": "dynamic" },
        { "name": "TamperSignal", "type": "string" },
        { "name": "Severity", "type": "string" }
      ]
    }
  }
}
```

3. **Create the data collection rule** in the workspace's region (no data collection endpoint is needed unless you use Private Link):

```json
{
  "location": "<workspace region>",
  "kind": "Direct",
  "properties": {
    "streamDeclarations": {
      "Custom-OrthalonAIEvents": {
        "columns": [
          { "name": "TimeGenerated", "type": "datetime" },
          { "name": "EventId", "type": "string" },
          { "name": "DedupKey", "type": "string" },
          { "name": "SchemaVersion", "type": "string" },
          { "name": "OrthalonTenantId", "type": "string" },
          { "name": "ObservedAt", "type": "datetime" },
          { "name": "ReceivedAt", "type": "datetime" },
          { "name": "Surface", "type": "string" },
          { "name": "SensorId", "type": "string" },
          { "name": "SensorVersion", "type": "string" },
          { "name": "ActorPseudonym", "type": "string" },
          { "name": "UserEmail", "type": "string" },
          { "name": "ActorOrigin", "type": "string" },
          { "name": "ActorOriginConfidence", "type": "real" },
          { "name": "ActorOriginEvidence", "type": "string" },
          { "name": "AgenticClient", "type": "string" },
          { "name": "AccountClass", "type": "string" },
          { "name": "AccountSource", "type": "string" },
          { "name": "AccountConfidence", "type": "real" },
          { "name": "AccountReason", "type": "string" },
          { "name": "BrowserContext", "type": "string" },
          { "name": "AccountDomain", "type": "string" },
          { "name": "AppDomain", "type": "string" },
          { "name": "ActionType", "type": "string" },
          { "name": "Direction", "type": "string" },
          { "name": "Decision", "type": "string" },
          { "name": "EnforcementOutcome", "type": "string" },
          { "name": "FailMode", "type": "string" },
          { "name": "FailModeReason", "type": "string" },
          { "name": "CoverageState", "type": "string" },
          { "name": "CoverageReason", "type": "string" },
          { "name": "Severity", "type": "string" },
          { "name": "PolicyId", "type": "string" },
          { "name": "PolicyRuleId", "type": "string" },
          { "name": "PolicyRevision", "type": "string" },
          { "name": "DetectorCategories", "type": "dynamic" },
          { "name": "Detections", "type": "dynamic" },
          { "name": "DetectorCount", "type": "int" },
          { "name": "RedactedExcerpt", "type": "string" },
          { "name": "File", "type": "dynamic" },
          { "name": "FalsePositiveReported", "type": "boolean" },
          { "name": "JustificationReason", "type": "string" },
          { "name": "IsTest", "type": "boolean" }
        ]
      },
      "Custom-OrthalonSensorCoverage": {
        "columns": [
          { "name": "TimeGenerated", "type": "datetime" },
          { "name": "EventId", "type": "string" },
          { "name": "DedupKey", "type": "string" },
          { "name": "SchemaVersion", "type": "string" },
          { "name": "OrthalonTenantId", "type": "string" },
          { "name": "ObservedAt", "type": "datetime" },
          { "name": "SensorId", "type": "string" },
          { "name": "InstallId", "type": "string" },
          { "name": "Browser", "type": "string" },
          { "name": "SensorVersion", "type": "string" },
          { "name": "CoverageState", "type": "string" },
          { "name": "PreviousCoverageState", "type": "string" },
          { "name": "CoverageIssues", "type": "dynamic" },
          { "name": "TamperSignal", "type": "string" },
          { "name": "Severity", "type": "string" }
        ]
      }
    },
    "destinations": {
      "logAnalytics": [
        {
          "workspaceResourceId": "<workspace resource id>",
          "name": "workspace"
        }
      ]
    },
    "dataFlows": [
      {
        "streams": ["Custom-OrthalonAIEvents"],
        "destinations": ["workspace"],
        "transformKql": "source",
        "outputStream": "Custom-OrthalonAIEvents_CL"
      },
      {
        "streams": ["Custom-OrthalonSensorCoverage"],
        "destinations": ["workspace"],
        "transformKql": "source",
        "outputStream": "Custom-OrthalonSensorCoverage_CL"
      }
    ]
  }
}
```

4. **Grant access.** On the DCR, under **Access control (IAM)**, assign the application the **Monitoring Metrics Publisher** role. A new assignment can take up to 30 minutes to apply; until then deliveries are held with a permissions error and resume on their own.
5. **Add the destination** in Orthalon: choose **Microsoft Sentinel**, and enter the tenant ID, client ID, client secret, the DCR's **logs ingestion endpoint** (in the DCR's JSON view; an `https://…ingest.monitor.azure.com` address), its **immutable ID** (`dcr-…`) and the stream name `Custom-OrthalonAIEvents`. Add `Custom-OrthalonSensorCoverage` as the coverage stream to receive coverage changes.
6. **Send a test event** and check: `OrthalonAIEvents_CL | where IsTest == true | take 5`. The first rows can take several minutes to appear.

Orthalon requests a token from `login.microsoftonline.com` with the client-credentials flow and scope `https://monitor.azure.com//.default`, and only ever sends it to the endpoint you entered, which must be a Microsoft ingestion host. Azure US Government and Azure China endpoints use their own authority and scope.

**Rotating the client secret:** create a second secret in Entra, paste it into the destination, then delete the old secret. Events are held, not lost, if the old secret expires first.

### Sample Sentinel detections (KQL)

Every query deduplicates on `DedupKey` first.

```kusto
// Credential detected where the block was not confirmed or enforced
OrthalonAIEvents_CL
| where TimeGenerated > ago(1h)
| summarize arg_max(TimeGenerated, *) by DedupKey
| where DetectorCategories has_any ("AWS_KEY", "GCP_KEY", "PRIVATE_KEY", "API_TOKEN", "PASSWORD")
| where EnforcementOutcome in ("ATTEMPTED", "FAILED") or Decision in ("ALLOWED", "COACHED_SENT")
| project TimeGenerated, ActorPseudonym, UserEmail, AppDomain, Surface, Decision,
    EnforcementOutcome, CoverageState, CoverageReason, FailMode, FailModeReason, EventId
```

```kusto
// Controls that failed open, by reason
OrthalonAIEvents_CL
| where TimeGenerated > ago(24h)
| summarize arg_max(TimeGenerated, *) by DedupKey
| where FailMode == "FAIL_OPEN" or EnforcementOutcome == "FAILED"
| summarize Events = count(), Users = dcount(ActorPseudonym) by FailModeReason, Surface, AppDomain
| order by Events desc
```

```kusto
// Repeated coaching overrides by one person (5 or more in an hour)
OrthalonAIEvents_CL
| where TimeGenerated > ago(1h)
| summarize arg_max(TimeGenerated, *) by DedupKey
| where Decision == "COACHED_SENT"
| summarize Overrides = count(), Apps = make_set(AppDomain) by ActorPseudonym, UserEmail
| where Overrides >= 5
```

```kusto
// Sensitive data on personal, external or unknown accounts
OrthalonAIEvents_CL
| where TimeGenerated > ago(24h)
| summarize arg_max(TimeGenerated, *) by DedupKey
| where AccountClass in ("PERSONAL", "EXTERNAL", "UNKNOWN") and DetectorCount > 0
| summarize Events = count() by AccountClass, AccountSource, AppDomain
```

```kusto
// Agent-originated or unverifiable activity touching sensitive data
OrthalonAIEvents_CL
| where TimeGenerated > ago(24h)
| summarize arg_max(TimeGenerated, *) by DedupKey
| where (ActorOrigin in ("AGENT", "MIXED") or isnotempty(AgenticClient)) and DetectorCount > 0
| project TimeGenerated, ActorOrigin, ActorOriginEvidence, AgenticClient, Surface, AppDomain,
    Decision, EnforcementOutcome, CoverageState
```

```kusto
// Browser sensors removed, disabled or stale
OrthalonSensorCoverage_CL
| where TimeGenerated > ago(24h)
| summarize arg_max(TimeGenerated, *) by DedupKey
| where CoverageState in ("UNINSTALLED", "ENFORCEMENT_DISABLED", "MISSING_PERMISSIONS", "STALE")
| summarize Sensors = dcount(InstallId) by CoverageState, TamperSignal, Browser
```

```kusto
// The stream itself went quiet (schedule hourly)
OrthalonAIEvents_CL
| summarize LastEvent = max(TimeGenerated)
| where LastEvent < ago(6h)
```

## Splunk HTTP Event Collector

1. In Splunk, create an HTTP Event Collector token (**Settings → Data inputs → HTTP Event Collector**). Choose an allowed index, for example `orthalon`. Indexer acknowledgment may be on or off; Orthalon sends a request channel either way.
2. Add the destination: choose **Splunk HTTP Event Collector**, enter the collector URL (`https://<host>:8088/services/collector/event`, or `https://http-inputs-<stack>.splunkcloud.com/services/collector/event` on Splunk Cloud), the token and, optionally, the index.
3. **Send a test event** and search `index=orthalon sourcetype="orthalon:ai_event" test=true`.

Orthalon reads the HEC status code, not only the HTTP status: an invalid or disabled token (codes 1–4, 21, 22) or an unknown index (7) holds the stream until you fix it; a busy server (9) or full queue (26, 27) is retried; a malformed event (5, 6, 12, 13, 15) is dead-lettered. **Rotating the token:** create a new token, paste it into the destination, then disable the old one.

### Sample Splunk detections (SPL)

```
index=orthalon sourcetype="orthalon:ai_event" earliest=-1h
| dedup dedupKey
| search detectorCategories{} IN ("AWS_KEY","GCP_KEY","PRIVATE_KEY","API_TOKEN","PASSWORD")
| where 'enforcement.outcome' IN ("ATTEMPTED","FAILED") OR decision IN ("ALLOWED","COACHED_SENT")
| table _time actor.pseudonym user appDomain sensor.surface decision enforcement.outcome coverage.state coverage.reason enforcement.failMode eventId
```

```
index=orthalon sourcetype="orthalon:ai_event" earliest=-24h
| dedup dedupKey
| where 'enforcement.failMode'="FAIL_OPEN" OR 'enforcement.outcome'="FAILED"
| stats count AS events dc(actor.pseudonym) AS users BY enforcement.failModeReason sensor.surface appDomain
| sort - events
```

```
index=orthalon sourcetype="orthalon:ai_event" decision="COACHED_SENT" earliest=-1h
| dedup dedupKey
| stats count AS overrides values(appDomain) AS apps BY actor.pseudonym user
| where overrides >= 5
```

```
index=orthalon sourcetype="orthalon:ai_event" earliest=-24h
| dedup dedupKey
| search account.class IN ("PERSONAL","EXTERNAL","UNKNOWN") detectorCount>0
| stats count BY account.class account.source appDomain
```

```
index=orthalon sourcetype="orthalon:sensor_coverage" earliest=-24h
| dedup dedupKey
| search coverage.state IN ("UNINSTALLED","ENFORCEMENT_DISABLED","MISSING_PERMISSIONS","STALE")
| stats dc(sensor.installId) AS sensors BY coverage.state coverage.tamperSignal sensor.browser
```

## Signed webhook

Orthalon generates a signing secret, shown once. Each request carries `x-orthalon-timestamp` and `x-orthalon-signature: v1=<hex HMAC-SHA256 of timestamp + "." + body>`, and `x-orthalon-schema-version`. Reject a request whose timestamp is more than five minutes old, and compare signatures in constant time.

**Rotating the secret** (**Rotate secret**): `x-orthalon-signature` is signed with the new secret at once, and for 24 hours `x-orthalon-signature-previous` carries the same signature made with the old secret. A receiver still holding the old secret verifies the previous header until you deploy the new one; a receiver with the new secret needs no change.

## Replay

- **Refused events:** **Details → Replay** re-sends the dead-lettered events of that destination.
- **A time range:** **Details → Send again from a time** re-sends everything stored since then, up to 30 days back, for example after restoring a SIEM from backup. Records keep their `eventId` and `dedupKey`.

## Incident triage playbook

1. **Confirm what happened, not just what was decided.** Read `enforcement.outcome` beside `decision`. `CONFIRMED` means the in-path control stopped it; `ENFORCED` means the sensor applied its control in its own path; `ATTEMPTED` or `FAILED` means you must assume the data may have left.
2. **Read the coverage.** `PARTIAL`, `NOT_OBSERVED` or `UNSUPPORTED` with a reason (`FILE_TOO_LARGE`, `ATTACHMENT_NOT_INSPECTED`, `AGENT_ACTIONS_NOT_OBSERVED`, `DESTINATION_ONLY`) bounds what Orthalon could see. Never close an incident as "no risk" because coverage was not full.
3. **Check the fail rule.** `FAIL_OPEN` with a reason is a known, labelled bypass; review whether that rule should fail closed for this data.
4. **Identify the actor.** `actor.origin` and its evidence say whether a person or an agent acted. `UNKNOWN` with `AGENTIC_CLIENT` means an agentic browser was involved and its own actions were not observed. Correlate `actor.pseudonym` across events; map it to a person only through your Orthalon console.
5. **Check the account.** `PERSONAL`, `EXTERNAL` or `UNKNOWN` accounts, with `account.source` and `account.reason`, tell you whether the data went to an account your organization controls.
6. **Check the sensor.** Look for coverage changes for the same sensor around the event (uninstalled, stale, enforcement disabled). A gap there can mean events are missing, not that nothing happened.
7. **Trace the evidence.** Open the event in **Violations** by `eventId`: it shows the policy and rule (`policy.id`, `policy.ruleId`, `policy.revision`), the sensor version and, on Enterprise with prompt evidence enabled, the redacted evidence.
8. **Contain.** Rotate any credential whose type appears in `detectorCategories` if the outcome is not `CONFIRMED`; tighten the policy (block or require approval) for the application and account class; re-run the scenario in the policy simulator.
9. **Close with the record.** Note the decision, outcome, coverage and actor origin in the case so the decision trail matches what Orthalon recorded.
