Detection rules › Kusto

Threat Essentials - NRT User added to Microsoft Entra ID Privileged Groups

Status
available
Severity
medium
Time window
14d
Source
github.com/Azure/Azure-Sentinel

This will alert when a user is added to any of the Privileged Groups. For further information on AuditLogs please see https://docs.microsoft.com/azure/active-directory/reports-monitoring/reference-audit-activities. For Administrator role permissions in Microsoft Entra ID please see https://docs.microsoft.com/azure/active-directory/users-groups-roles/directory-assign-admin-roles

MITRE ATT&CK coverage

Event coverage

Rules detecting the same action

Other rules on this platform that filter on the same API call or operation.

Rule body kusto

id: 0a627f29-f0dd-4924-be92-c3d6dac84367
name: Threat Essentials - NRT User added to Microsoft Entra ID Privileged Groups
description: |
  'This will alert when a user is added to any of the Privileged Groups.
  For further information on AuditLogs please see https://docs.microsoft.com/azure/active-directory/reports-monitoring/reference-audit-activities.
  For Administrator role permissions in Microsoft Entra ID please see https://docs.microsoft.com/azure/active-directory/users-groups-roles/directory-assign-admin-roles'
severity: Medium
status: Available
requiredDataConnectors:
  - connectorId: AzureActiveDirectory
    dataTypes:
      - AuditLogs
queryFrequency: 1d
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0     
tactics:
  - Persistence
  - PrivilegeEscalation
relevantTechniques:
  - T1098
  - T1078
tags:
  - DEV-0537
query: |
  let OperationList = dynamic(["Add member to role","Add member to role in PIM requested (permanent)"]);
  let PrivilegedGroups = dynamic(["UserAccountAdmins","PrivilegedRoleAdmins","TenantAdmins"]);
  AuditLogs
  //| where LoggedByService =~ "Core Directory"
  | where Category =~ "RoleManagement"
  | where OperationName in~ (OperationList)
  | mv-expand TargetResources
  | extend modProps = parse_json(TargetResources).modifiedProperties
  | mv-expand bagexpansion=array modProps
  | evaluate bag_unpack(modProps)
  | extend displayName = column_ifexists("displayName", "NotAvailable"), newValue = column_ifexists("newValue", "NotAvailable")
  | where displayName =~ "Role.WellKnownObjectName"
  | extend DisplayName = displayName, GroupName = replace('"','',newValue)
  | extend initByApp = parse_json(InitiatedBy).app, initByUser = parse_json(InitiatedBy).user
  | extend AppId = initByApp.appId,
  InitiatedByDisplayName = case(isnotempty(initByApp.displayName), initByApp.displayName, isnotempty(initByUser.displayName), initByUser.displayName, "not available"),
  ServicePrincipalId = tostring(initByApp.servicePrincipalId),
  ServicePrincipalName = tostring(initByApp.servicePrincipalName),
  UserId = initByUser.id,
  UserIPAddress = initByUser.ipAddress,
  UserRoles = initByUser.roles,
  UserPrincipalName = tostring(initByUser.userPrincipalName),
  TargetUserPrincipalName = tostring(TargetResources.userPrincipalName)
  | where GroupName in~ (PrivilegedGroups)
  // If you don't want to alert for operations from PIM, remove below filtering for MS-PIM.
  //| where InitiatedByDisplayName != "MS-PIM" and InitiatedByDisplayName != "MS-PIM-Fairfax"
  | project TimeGenerated, AADOperationType, Category, OperationName, AADTenantId, AppId, InitiatedByDisplayName, ServicePrincipalId, ServicePrincipalName, DisplayName, GroupName, UserId, UserIPAddress, UserRoles, UserPrincipalName, TargetUserPrincipalName
  | extend InitiatorAccount = case(isnotempty(ServicePrincipalName), ServicePrincipalName, isnotempty(ServicePrincipalId), ServicePrincipalId, isnotempty(UserPrincipalName), UserPrincipalName, "not available")
  | extend InitiatorName = iif(InitiatorAccount has '@',tostring(split(InitiatorAccount,'@',0)[0]),"")
  | extend InitiatorUPNSuffix = iif(InitiatorAccount has '@',tostring(split(InitiatorAccount,'@',1)[0]),"")
  | extend InitiatorAadUserId = iif(InitiatorAccount !has '@',InitiatorAccount,"")
  | extend TargetName = iif(TargetUserPrincipalName has '@',tostring(split(TargetUserPrincipalName,'@',0)[0]),"")
  | extend TargetUPNSuffix = iif(TargetUserPrincipalName has '@',tostring(split(TargetUserPrincipalName,'@',1)[0]),"")
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: Name
        columnName: InitiatorName
      - identifier: UPNSuffix
        columnName: InitiatorUPNSuffix
      - identifier: AadUserId
        columnName: InitiatorAadUserId
  - entityType: Account
    fieldMappings:
      - identifier: Name
        columnName: TargetName
      - identifier: UPNSuffix
        columnName: TargetUPNSuffix
version: 1.0.4
kind: NRT

Stages and Predicates

Parameters

let OperationList = dynamic(["Add member to role","Add member to role in PIM requested (permanent)"]);
let PrivilegedGroups = dynamic(["UserAccountAdmins","PrivilegedRoleAdmins","TenantAdmins"]);

Stage 1: source

AuditLogs

Stage 2: where

| where Category =~ "RoleManagement"

Stage 3: where

| where OperationName in~ (OperationList)

Stage 4: mv-expand

| mv-expand TargetResources

Stage 5: extend

| extend modProps = parse_json(TargetResources).modifiedProperties

Stage 6: mv-expand

| mv-expand bagexpansion=array modProps

Stage 7: evaluate

| evaluate bag_unpack(modProps)

Stage 8: extend

| extend displayName = column_ifexists("displayName", "NotAvailable"), newValue = column_ifexists("newValue", "NotAvailable")

Stage 9: where

| where displayName =~ "Role.WellKnownObjectName"

Stage 10: extend (3 consecutive steps)

| extend DisplayName = displayName, GroupName = replace('"','',newValue)
| extend initByApp = parse_json(InitiatedBy).app, initByUser = parse_json(InitiatedBy).user
| extend AppId = initByApp.appId,
InitiatedByDisplayName = case(isnotempty(initByApp.displayName), initByApp.displayName, isnotempty(initByUser.displayName), initByUser.displayName, "not available"),
ServicePrincipalId = tostring(initByApp.servicePrincipalId),
ServicePrincipalName = tostring(initByApp.servicePrincipalName),
UserId = initByUser.id,
UserIPAddress = initByUser.ipAddress,
UserRoles = initByUser.roles,
UserPrincipalName = tostring(initByUser.userPrincipalName),
TargetUserPrincipalName = tostring(TargetResources.userPrincipalName)

Stage 11: where

| where GroupName in~ (PrivilegedGroups)

Stage 12: project

| project TimeGenerated, AADOperationType, Category, OperationName, AADTenantId, AppId, InitiatedByDisplayName, ServicePrincipalId, ServicePrincipalName, DisplayName, GroupName, UserId, UserIPAddress, UserRoles, UserPrincipalName, TargetUserPrincipalName

Stage 13: extend (6 consecutive steps)

| extend InitiatorAccount = case(isnotempty(ServicePrincipalName), ServicePrincipalName, isnotempty(ServicePrincipalId), ServicePrincipalId, isnotempty(UserPrincipalName), UserPrincipalName, "not available")
| extend InitiatorName = iif(InitiatorAccount has '@',tostring(split(InitiatorAccount,'@',0)[0]),"")
| extend InitiatorUPNSuffix = iif(InitiatorAccount has '@',tostring(split(InitiatorAccount,'@',1)[0]),"")
| extend InitiatorAadUserId = iif(InitiatorAccount !has '@',InitiatorAccount,"")
| extend TargetName = iif(TargetUserPrincipalName has '@',tostring(split(TargetUserPrincipalName,'@',0)[0]),"")
| extend TargetUPNSuffix = iif(TargetUserPrincipalName has '@',tostring(split(TargetUserPrincipalName,'@',1)[0]),"")
InitiatorAccount =
ifisnotempty(ServicePrincipalName)ServicePrincipalName
elifisnotempty(ServicePrincipalId)ServicePrincipalId
elifisnotempty(UserPrincipalName)UserPrincipalName
else"not available"

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
Categoryeq
  • RoleManagement
GroupNamein
  • PrivilegedRoleAdmins
  • TenantAdmins
  • UserAccountAdmins
OperationNamein
  • Add member to role
  • Add member to role in PIM requested (permanent)
displayNameeq
  • Role.WellKnownObjectName

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
AADOperationTypeproject
AADTenantIdproject
AppIdproject
Categoryproject
DisplayNameproject
GroupNameproject
InitiatedByDisplayNameproject
OperationNameproject
ServicePrincipalIdproject
ServicePrincipalNameproject
TargetUserPrincipalNameproject
TimeGeneratedproject
UserIPAddressproject
UserIdproject
UserPrincipalNameproject
UserRolesproject
InitiatorAccountextend
InitiatorNameextend
InitiatorUPNSuffixextend
InitiatorAadUserIdextend
TargetNameextend
TargetUPNSuffixextend