Detection rules › Kusto

Dataverse - Anomalous application user activity

Status
available
Severity
medium
Time window
14d
Group by
AnomalyTimeGenerated, InstanceUrl, OriginalObjectId, UserId
Source
github.com/Azure/Azure-Sentinel

Identifies anomalies in activity patterns of Dataverse application (non-interactive) users, based on activity falling outside the normal pattern of use.

MITRE ATT&CK coverage

Rule body kusto

id: 0820da12-e895-417f-9175-7c256fcfb33e
kind: Scheduled
name: Dataverse - Anomalous application user activity
description: Identifies anomalies in activity patterns of Dataverse application (non-interactive)
  users, based on activity falling outside the normal pattern of use.
severity: Medium
status: Available
requiredDataConnectors:
  - connectorId: Dataverse
    dataTypes:
      - DataverseActivity
queryFrequency: 5h
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - CredentialAccess
  - Execution
  - Persistence
relevantTechniques:
  - T1528
  - T1569
  - T0871
  - T0834
  - T0859
query: |
  let query_lookback = 14d;
  let query_frequency = 5h;
  let anomaly_threshold = 2.5;
  let seasonality = -1;
  let trend = 'linefit';
  let step_duration = 5h;
  let app_user_regex = "^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\\.com$";
  let guid_regex = "([0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12})";
  let application_users = DataverseActivity
      | where TimeGenerated >= ago(query_frequency)
      | where UserId !endswith "@onmicrosoft.com" and UserId != "Unknown"
      | summarize by UserId
      | where split(UserId, "@")[1] matches regex app_user_regex;
  DataverseActivity
  | where TimeGenerated >= startofday(ago(query_lookback))
  | where UserId in (application_users)
  | where isnotempty(OriginalObjectId)
  | make-series TotalEvents = count() default=0 on TimeGenerated from startofday(ago(query_lookback)) to now() step step_duration by UserId, InstanceUrl, OriginalObjectId
  | extend (Anomalies, Score, Baseline) = series_decompose_anomalies(TotalEvents, anomaly_threshold, seasonality, trend)
  | mv-expand
      TotalEvents to typeof(double),
      AnomalyTimeGenerated = TimeGenerated to typeof(datetime),
      Anomalies to typeof(double),
      Score to typeof(double),
      Baseline to typeof(long)
  | where Anomalies > 0
  | extend Details = bag_pack(
                         "TotalEvents",
                         TotalEvents,
                         "Anomalies",
                         Anomalies,
                         "Baseline",
                         Baseline,
                         "Score",
                         Score,
                         "OriginalObjectId",
                         OriginalObjectId
                     )
  | summarize Details = make_set(Details, 100) by UserId, InstanceUrl, AnomalyTimeGenerated
  | extend
      CloudAppId = int(32780),
      AadUserId = extract(guid_regex, 1, tostring(split(UserId, "@")[0]))
  | project
      AnomalyTimeGenerated,
      UserId,
      AadUserId,
      InstanceUrl,
      Details,
      CloudAppId
eventGroupingSettings:
  aggregationKind: AlertPerResult
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: AadUserId
        columnName: AadUserId
  - entityType: CloudApplication
    fieldMappings:
      - identifier: AppId
        columnName: CloudAppId
      - identifier: InstanceName
        columnName: InstanceUrl
alertDetailsOverride:
  alertDisplayNameFormat: 'Dataverse - Non-interactive account anomaly detected in
    {{InstanceUrl}} '
  alertDescriptionFormat: 'Anomaly detected on {{UserId}} in {{InstanceUrl}}.  Details:
    {{Details}}'
customDetails:
  InstranceUrl: InstanceUrl
version: 3.2.0

Stages and Predicates

Parameters

let query_lookback = 14d;
let query_frequency = 5h;
let anomaly_threshold = 2.5;
let seasonality = -1;
let trend = 'linefit';
let step_duration = 5h;
let app_user_regex = "^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\\.com$";
let guid_regex = "([0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12})";

Let binding: application_users

let application_users = DataverseActivity
    | where TimeGenerated >= ago(query_frequency)
    | where UserId !endswith "@onmicrosoft.com" and UserId != "Unknown"
    | summarize by UserId
    | where split(UserId, "@")[1] matches regex app_user_regex;

Derived from query_frequency, app_user_regex.

Stage 1: source

DataverseActivity

Stage 2: where

| where TimeGenerated >= startofday(ago(query_lookback))

Stage 3: where

| where UserId in (application_users)

References application_users (defined above).

Stage 4: where

| where isnotempty(OriginalObjectId)

The stages below score time-series anomalies (make-series, series_decompose_anomalies).

Stage 5: summarize

| make-series TotalEvents = count() default=0 on TimeGenerated from startofday(ago(query_lookback)) to now() step step_duration by UserId, InstanceUrl, OriginalObjectId

Stage 6: extend

| extend (Anomalies, Score, Baseline) = series_decompose_anomalies(TotalEvents, anomaly_threshold, seasonality, trend)

Stage 7: mv-expand

| mv-expand
    TotalEvents to typeof(double),
    AnomalyTimeGenerated = TimeGenerated to typeof(datetime),
    Anomalies to typeof(double),
    Score to typeof(double),
    Baseline to typeof(long)

Stage 8: where

| where Anomalies > 0

Stage 9: extend

| extend Details = bag_pack(
                       "TotalEvents",
                       TotalEvents,
                       "Anomalies",
                       Anomalies,
                       "Baseline",
                       Baseline,
                       "Score",
                       Score,
                       "OriginalObjectId",
                       OriginalObjectId
                   )

Stage 10: summarize

| summarize Details = make_set(Details, 100) by UserId, InstanceUrl, AnomalyTimeGenerated

Stage 11: extend

| extend
    CloudAppId = int(32780),
    AadUserId = extract(guid_regex, 1, tostring(split(UserId, "@")[0]))

Stage 12: project

| project
    AnomalyTimeGenerated,
    UserId,
    AadUserId,
    InstanceUrl,
    Details,
    CloudAppId

Indicators

Each row is a field, operator, and value that the rule matches. The corpus column counts how many other rules in the catalog look for the same combination: high numbers point to widely-used, community-vetted indicators. Blank or 1 shows that the indicator is specific to this rule.

FieldKindValues
Anomaliesgt
  • 0 transforms: cased corpus 6 (kusto 6)
OriginalObjectIdis_not_null
  • (no value, null check)
UserIdin
  • application_users transforms: cased

Output fields

Fields the rule emits when it matches. Chronicle authors list these in the outcome block; they appear on the detection and $risk_score drives alerting. Sentinel / Defender XDR rules build them up through project / summarize / extend stages. Sentinel maps these into alert fields via entityMappings and customDetails; Defender XDR custom detections surface them as alert fields directly.

FieldSource
AadUserIdproject
AnomalyTimeGeneratedproject
CloudAppIdproject
Detailsproject
InstanceUrlproject
UserIdproject