Detection rules › Kusto

Dataverse - Suspicious use of Web API

Status
available
Severity
medium
Time window
14d
Group by
AppDisplayName, AppId, ClientIp, IPAddress, InstanceUrl, UserId, UserPrincipalName
Source
github.com/Azure/Azure-Sentinel

Identifies sign-in across multiple Dataverse environments, breaching a predefined threshold, originating from a user with IP address that was used to sign-into the well known Microsoft Entra app registration.

MITRE ATT&CK coverage

Rule body kusto

id: 8a6ecba2-ccfe-4c8c-b086-fa3e6ff7fa86
kind: Scheduled
name: Dataverse - Suspicious use of Web API
description: Identifies sign-in across multiple Dataverse environments, breaching
  a predefined threshold, originating from a user with IP address that was used to
  sign-into the well known Microsoft Entra app registration.
severity: Medium
status: Available
requiredDataConnectors:
  - connectorId: Dataverse
    dataTypes:
      - DataverseActivity
  - connectorId: AzureActiveDirectory
    dataTypes:
      - SigninLogs
queryFrequency: 1h
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - Execution
  - Exfiltration
  - Reconnaissance
  - Discovery
relevantTechniques:
  - T1106
  - T1567
  - T1595
  - T1526
  - T1580
query: |
  let query_frequency = 1h;
  let query_lookback = 24h;
  // AppID of the multi-tenant Dynamics 365 Example Client Application
  let well_known_app_id = "51f81489-12ee-4a9e-aaae-a2591f45987d";
  let environment_count_threshold = 10;
  SigninLogs
  | where TimeGenerated >= ago(query_lookback)
  // Comment out the line below to monitor activity from all Azure AD apps
  | where AppId == well_known_app_id
  | where ResourceIdentity == '00000007-0000-0000-c000-000000000000'
  | summarize FirstSeen = min(TimeGenerated) by AppId, UserPrincipalName, IPAddress, AppDisplayName
  | join kind=inner (
      DataverseActivity
      | where TimeGenerated >= ago(query_frequency)
      | where Message == "UserSignIn")
      on $left.UserPrincipalName == $right.UserId, $left.IPAddress == $right.ClientIp
  | where TimeGenerated between (FirstSeen .. (FirstSeen + 2h))
  | summarize InstanceCount = dcount(InstanceUrl, 4), FirstSeen = min(FirstSeen) by UserId, ClientIp, InstanceUrl, AppDisplayName, AppId
  | where InstanceCount > environment_count_threshold
  | extend
      CloudAppId = int(32780),
      AccountName = tostring(split(UserId, '@')[0]),
      UPNSuffix = tostring(split(UserId, '@')[1])
  | project
      FirstSeen,
      UserId,
      ClientIp,
      AppDisplayName,
      AppId,
      InstanceUrl,
      CloudAppId,
      AccountName,
      UPNSuffix
eventGroupingSettings:
  aggregationKind: AlertPerResult
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: Name
        columnName: AccountName
      - identifier: UPNSuffix
        columnName: UPNSuffix
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: ClientIp
  - entityType: CloudApplication
    fieldMappings:
      - identifier: AppId
        columnName: CloudAppId
      - identifier: InstanceName
        columnName: InstanceUrl
alertDetailsOverride:
  alertDisplayNameFormat: Dataverse - Suspicious Web API sign-in activity
  alertDescriptionFormat: '{{UserId}} sign-in activity generated in {{InstanceUrl}}.
    The app used was a well known multi-tenant app not owned or registered by the
    organization.'
version: 3.2.0

Stages and Predicates

Parameters

let query_frequency = 1h;
let query_lookback = 24h;
let well_known_app_id = "51f81489-12ee-4a9e-aaae-a2591f45987d";
let environment_count_threshold = 10;

Stage 1: source

SigninLogs

Stage 2: where

| where TimeGenerated >= ago(query_lookback)

Stage 3: where

| where AppId == well_known_app_id

Stage 4: where

| where ResourceIdentity == '00000007-0000-0000-c000-000000000000'

Stage 5: summarize

| summarize FirstSeen = min(TimeGenerated) by AppId, UserPrincipalName, IPAddress, AppDisplayName

Stage 6: join

| join kind=inner (
    DataverseActivity
    | where TimeGenerated >= ago(query_frequency)
    | where Message == "UserSignIn")
    on $left.UserPrincipalName == $right.UserId, $left.IPAddress == $right.ClientIp

Stage 7: where

| where TimeGenerated between (FirstSeen .. (FirstSeen + 2h))

Stage 8: summarize

| summarize InstanceCount = dcount(InstanceUrl, 4), FirstSeen = min(FirstSeen) by UserId, ClientIp, InstanceUrl, AppDisplayName, AppId
Threshold
gt 10

Stage 9: where

| where InstanceCount > environment_count_threshold

Stage 10: extend

| extend
    CloudAppId = int(32780),
    AccountName = tostring(split(UserId, '@')[0]),
    UPNSuffix = tostring(split(UserId, '@')[1])

Stage 11: project

| project
    FirstSeen,
    UserId,
    ClientIp,
    AppDisplayName,
    AppId,
    InstanceUrl,
    CloudAppId,
    AccountName,
    UPNSuffix

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
AppIdeq
  • 51f81489-12ee-4a9e-aaae-a2591f45987d transforms: cased
InstanceCountgt
  • 10 transforms: cased
Messageeq
  • UserSignIn transforms: cased corpus 6 (kusto 6)
ResourceIdentityeq
  • 00000007-0000-0000-c000-000000000000 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
AccountNameproject
AppDisplayNameproject
AppIdproject
ClientIpproject
CloudAppIdproject
FirstSeenproject
InstanceUrlproject
UPNSuffixproject
UserIdproject