LoFP LoFP / legitimate first-party or line-of-business applications that use delegated permissions and enumerate several graph resources during onboarding or sync may match. baseline known app ids and tune thresholds or path lists for your tenant.

Techniques

Sample rules

Microsoft Graph Multi-Category Reconnaissance Burst

Description

Detects Microsoft Graph activity from delegated user tokens (public client, client_auth_method 0) where a single user session and source IP rapidly touches multiple high-value Graph paths indicative of reconnaissance. The query classifies requests into categories such as role discovery, cross-tenant relationship queries, mailbox paths, contact harvesting, and organization or licensing metadata. When three or more distinct categories appear within a short burst window, it suggests a broad enumeration playbook rather than normal application traffic.

Detection logic

from logs-azure.graphactivitylogs-* metadata _id, _version, _index

// Graph calls via delegated user tokens (any status, any method)
| where event.dataset == "azure.graphactivitylogs"
  and azure.graphactivitylogs.properties.c_idtyp == "user"
  and azure.graphactivitylogs.properties.client_auth_method == 0

// high-value recon endpoints by url.path
| eval Esql.is_role_enum = case(
    url.path like "*roleManagement/directory*"
      or url.path like "*memberOf/microsoft.graph.directoryRole*"
      or url.path like "*transitiveRoleAssignments*",
    true,
    false
  )
| eval Esql.is_cross_tenant_enum = case(
    url.path like "*tenantRelationships*"
      or url.path like "*getResourceTenants*",
    true,
    false
  )
| eval Esql.is_mailbox_recon = case(
    url.path like "*mailboxSettings*"
      or url.path like "*mailFolders*"
      or url.path like "*messages*"
      or url.path like "*inbox*",
    true,
    false
  )
| eval Esql.is_contact_harvest = case(
    url.path like "*contacts*"
      or url.path like "*contactFolders*",
    true,
    false
  )
| eval Esql.is_org_recon = case(
    url.path like "*subscribedSkus*"
      or url.path like "*appRoleAssign*"
      or (
          url.path like "*/organization*"
          and not url.path like "*branding*"
          and not url.path like "*localizations*"
        ),
    true,
    false
  )

// Combine: is this request hitting a high-value endpoint?
| eval Esql.is_high_value = case(
    Esql.is_role_enum or Esql.is_cross_tenant_enum or Esql.is_mailbox_recon
      or Esql.is_contact_harvest or Esql.is_org_recon,
    true,
    false
  )
| where Esql.is_high_value == true

// Classify each hit into a recon category
| eval Esql.recon_category = case(
    Esql.is_role_enum, "role_discovery",
    Esql.is_cross_tenant_enum, "cross_tenant_recon",
    Esql.is_mailbox_recon, "mailbox_recon",
    Esql.is_contact_harvest, "contact_harvesting",
    Esql.is_org_recon, "org_and_licensing_recon",
    "other"
  )

// Flag failed requests (recon that errored is still recon)
| eval Esql.is_failed_request = case(
    http.response.status_code >= 400, true, false
  )

// Aggregate per user + session + source IP
| stats
    Esql.total_high_value_calls = count(*),
    Esql.distinct_categories = count_distinct(Esql.recon_category),
    Esql.distinct_paths = count_distinct(url.path),
    Esql.failed_calls = sum(case(Esql.is_failed_request, 1, 0)),
    Esql.categories = values(Esql.recon_category),
    Esql.sample_paths = values(url.path),
    Esql.http_methods = values(http.request.method),
    Esql.status_codes = values(http.response.status_code),
    Esql.first_seen = min(@timestamp),
    Esql.last_seen = max(@timestamp),
    Esql.user_agents = values(user_agent.original),
    Esql.app_ids = values(azure.graphactivitylogs.properties.app_id)
  by
    azure.graphactivitylogs.properties.user_principal_object_id,
    source.ip,
    source.`as`.organization.name,
    source.`as`.number,
    azure.graphactivitylogs.properties.c_sid,
    azure.tenant_id

// Threshold: 3+ distinct recon categories 
| where Esql.distinct_categories >= 4 and Esql.total_high_value_calls >= 20

// Burst duration in seconds
| eval Esql.burst_duration_seconds = date_diff("seconds", Esql.first_seen, Esql.last_seen)
| where Esql.burst_duration_seconds <= 60

| keep
    azure.graphactivitylogs.properties.user_principal_object_id,
    azure.graphactivitylogs.properties.c_sid,
    azure.tenant_id,
    source.ip,
    source.`as`.organization.name,
    source.`as`.number,
    Esql.*