Detection rules › Kusto

Power Apps - Bulk sharing of Power Apps to newly created guest users

Status
available
Severity
medium
Time window
14d
Group by
AppId, InvitedById, InvitedByUPN, InvitedId, TargetPrincipalId
Source
github.com/Azure/Azure-Sentinel

Identifies unusual bulk sharing, based on a predefined threshold in the query, of Power Apps to newly created Microsoft Entra guest users.

MITRE ATT&CK coverage

TacticTechniques
Resource Development
Initial Access
Lateral Movement

Telemetry coverage

Rules detecting the same action

These rules filter on the same operation.

Rule body

id: 943acfa0-9285-4eb0-a9c0-42e36177ef19
kind: Scheduled
name: Power Apps - Bulk sharing of Power Apps to newly created guest users
description: Identifies unusual bulk sharing, based on a predefined threshold in the
  query, of Power Apps to newly created Microsoft Entra guest users.
severity: Medium
status: Available
requiredDataConnectors:
  - connectorId: PowerPlatformAdmin
    dataTypes:
      - PowerPlatformAdminActivity
  - connectorId: AzureActiveDirectory
    dataTypes:
      - AuditLogs
queryFrequency: 1h
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - ResourceDevelopment
  - InitialAccess
  - LateralMovement
relevantTechniques:
  - T1587
  - T1566
  - T1534
query: |
  ////////////
  // threshold = If the number of unique accounts that a power app is shared with is greater than
  // threshold than it'll trigger an alert. A threshold of 5 is good to start with.
  // However, if this is giving too many false positives, please adjust the threshold.
  ////////////
  let threshold = 5;
  ////////////
  // Please replace the allowed_domains with a list of domains of your partners/sibling orgs
  // with whom you generally share power apps with. This will allow us to filter
  // legitimate bulk sharing attempts. Avoid using domains such as gmail, outlook, etc.
  ///////////
  let allowed_domains = pack_array("contoso.com");
  let query_frequency = 1h;
  let query_lookback = 14d;
  PowerPlatformAdminActivity
  | where TimeGenerated >= ago(query_frequency)
  | where EventOriginalType == "PowerAppPermissionEdited"
  | extend Properties = tostring(PropertyCollection)
  | extend AppId = extract(@'"powerplatform.analytics.resource.power_app.id","Value":"([^"]+)"', 1, Properties)
  | extend AppId = tolower(replace_string(AppId, '/providers/Microsoft.PowerApps/apps/', ''))
  | extend TargetPrincipalId = extract(@'"targetuser.id","Value":"([^"]+)"', 1, Properties)
  | join kind=leftouter (
      AuditLogs
      | where ActivityDateTime >= ago(query_lookback)
      | where SourceSystem =~ "Azure AD" and OperationName == "Invite external user"
      | where Result =~ "success"
      | extend InvitedOrgEmail = tostring(parse_json(AdditionalDetails[5])['value'])
      | extend InvitedOrgDomain = tostring(split(InvitedOrgEmail, "@")[1])
      | where not(InvitedOrgDomain has_any(allowed_domains))
      | extend
          InvitedById = tostring(parse_json(InitiatedBy)['user']['id']),
          InvitedByUPN = tostring(parse_json(InitiatedBy)['user']['userPrincipalName']),
          InvitedEmail = tostring(parse_json(TargetResources[0])['userPrincipalName']),
          InvitedId = tostring(parse_json(TargetResources[0])['id'])
      | summarize by InvitedById, InvitedByUPN, InvitedEmail, InvitedId, InvitedOrgDomain)
      on $left.TargetPrincipalId == $right.InvitedId
  | where isnotempty(InvitedId)
  | summarize
      StartTime = min(TimeGenerated),
      EndTime = max(TimeGenerated),
      TargetedUsersCount=dcount(TargetPrincipalId),
      TargetedObjectIds = make_set(TargetPrincipalId, 1000),
      InvitedDomains = make_set(InvitedOrgDomain, 1000),
      InvitedEmailAddresses = make_set(InvitedEmail, 1000)
      by AppId, InvitedById, InvitedByUPN
  | extend
      PowerAppsEntityId = 27593,
      AccountName = tostring(split(InvitedByUPN, '@')[0]),
      UPNSuffix = tostring(split(InvitedByUPN, '@')[1])
  | project
      StartTime,
      EndTime,
      InvitedByUPN,
      InvitedById,
      InvitedDomains,
      InvitedEmailAddresses,
      TargetedUsersCount,
      TargetedObjectIds,
      AppId,
      PowerAppsEntityId,
      AccountName,
      UPNSuffix
eventGroupingSettings:
  aggregationKind: SingleAlert
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: Name
        columnName: AccountName
      - identifier: UPNSuffix
        columnName: UPNSuffix
  - entityType: CloudApplication
    fieldMappings:
      - identifier: AppId
        columnName: PowerAppsEntityId
      - identifier: InstanceName
        columnName: AppId
alertDetailsOverride:
  alertDisplayNameFormat: Power Apps - app shared with recently created external guest
    accounts
  alertDescriptionFormat: '{{InvitedByUPN}}  shared an app with {{TargetedUsersCount}}
    recently added guest user accounts that are not on the list of allowed partner
    domains. List of domain s {{InvitedDomains}}'
customDetails:
  PowerAppsApp: AppId
version: 3.2.0

Stages and Predicates

Parameters

let threshold = 5;
let allowed_domains = pack_array("contoso.com");
let query_frequency = 1h;
let query_lookback = 14d;

Stage 1: source

PowerPlatformAdminActivity

Stage 2: where

| where TimeGenerated >= ago(query_frequency)

Stage 3: where

| where EventOriginalType == "PowerAppPermissionEdited"

Stage 4: extend (4 consecutive steps)

| extend Properties = tostring(PropertyCollection)
| extend AppId = extract(@'"powerplatform.analytics.resource.power_app.id","Value":"([^"]+)"', 1, Properties)
| extend AppId = tolower(replace_string(AppId, '/providers/Microsoft.PowerApps/apps/', ''))
| extend TargetPrincipalId = extract(@'"targetuser.id","Value":"([^"]+)"', 1, Properties)

Stage 5: join

| join kind=leftouter (
    AuditLogs
    | where ActivityDateTime >= ago(query_lookback)
    | where SourceSystem =~ "Azure AD" and OperationName == "Invite external user"
    | where Result =~ "success"
    | extend InvitedOrgEmail = tostring(parse_json(AdditionalDetails[5])['value'])
    | extend InvitedOrgDomain = tostring(split(InvitedOrgEmail, "@")[1])
    | where not(InvitedOrgDomain has_any(allowed_domains))
    | extend
        InvitedById = tostring(parse_json(InitiatedBy)['user']['id']),
        InvitedByUPN = tostring(parse_json(InitiatedBy)['user']['userPrincipalName']),
        InvitedEmail = tostring(parse_json(TargetResources[0])['userPrincipalName']),
        InvitedId = tostring(parse_json(TargetResources[0])['id'])
    | summarize by InvitedById, InvitedByUPN, InvitedEmail, InvitedId, InvitedOrgDomain)
    on $left.TargetPrincipalId == $right.InvitedId

Stage 6: where

| where isnotempty(InvitedId)

Stage 7: summarize

| summarize
    StartTime = min(TimeGenerated),
    EndTime = max(TimeGenerated),
    TargetedUsersCount=dcount(TargetPrincipalId),
    TargetedObjectIds = make_set(TargetPrincipalId, 1000),
    InvitedDomains = make_set(InvitedOrgDomain, 1000),
    InvitedEmailAddresses = make_set(InvitedEmail, 1000)
    by AppId, InvitedById, InvitedByUPN

Stage 8: extend

| extend
    PowerAppsEntityId = 27593,
    AccountName = tostring(split(InvitedByUPN, '@')[0]),
    UPNSuffix = tostring(split(InvitedByUPN, '@')[1])

Stage 9: project

| project
    StartTime,
    EndTime,
    InvitedByUPN,
    InvitedById,
    InvitedDomains,
    InvitedEmailAddresses,
    TargetedUsersCount,
    TargetedObjectIds,
    AppId,
    PowerAppsEntityId,
    AccountName,
    UPNSuffix

Exclusions

The rule actively suppresses these predicates.

FieldKindExcluded valuesSearch
InvitedOrgDomainmatchallowed_domainsexcludes:InvitedOrgDomain field:"InvitedOrgDomain" value:"allowed_domains"

Indicators

These rows show field, operator, and value matches.

Output fields

These fields are emitted when the rule matches.

FieldSource
AccountNameproject
AppIdproject
EndTimeproject
InvitedByIdproject
InvitedByUPNproject
InvitedDomainsproject
InvitedEmailAddressesproject
PowerAppsEntityIdproject
StartTimeproject
TargetedObjectIdsproject
TargetedUsersCountproject
UPNSuffixproject