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 DevelopmentT1587 Develop Capabilities
Initial AccessT1566 Phishing
Lateral MovementT1534 Internal Spearphishing

Event coverage

Rule body kusto

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

Top-level NOT(...) conjuncts: predicates this rule actively suppresses.

FieldKindExcluded values
InvitedOrgDomainmatchallowed_domains

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
EventOriginalTypeeq
  • PowerAppPermissionEdited transforms: cased
InvitedIdis_not_null
  • (no value, null check)
OperationNameeq
  • Invite external user transforms: cased
Resulteq
  • success
SourceSystemeq
  • Azure AD

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
AppIdproject
EndTimeproject
InvitedByIdproject
InvitedByUPNproject
InvitedDomainsproject
InvitedEmailAddressesproject
PowerAppsEntityIdproject
StartTimeproject
TargetedObjectIdsproject
TargetedUsersCountproject
UPNSuffixproject