text code block: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.*
Install detection rules in Elastic Security
Detect Microsoft Graph Multi-Category Reconnaissance Burst in the Elastic Security detection engine by installing this rule into your Elastic Stack.
To setup this rule, check out the installation guide for Prebuilt Security Detection Rules(external, opens in a new tab or window).