diff --git a/rules/cross-platform/initial_access_azure_o365_with_network_alert.toml b/rules/cross-platform/initial_access_azure_o365_with_network_alert.toml index 329c2fd6e..d2cd0b274 100644 --- a/rules/cross-platform/initial_access_azure_o365_with_network_alert.toml +++ b/rules/cross-platform/initial_access_azure_o365_with_network_alert.toml @@ -2,7 +2,7 @@ creation_date = "2025/04/29" integration = ["azure", "o365"] maturity = "production" -updated_date = "2025/07/02" +updated_date = "2025/07/30" [rule] author = ["Elastic"] @@ -19,10 +19,10 @@ false_positives = [ from = "now-60m" language = "esql" license = "Elastic License v2" -name = "Microsoft Azure or Mail Sign-in from a Suspicious Source" +name = "Microsoft 365 or Entra ID Sign-in from a Suspicious Source" note = """## Triage and analysis -### Investigating Microsoft Azure or Mail Sign-in from a Suspicious Source +### Investigating Microsoft 365 or Entra ID Sign-in from a Suspicious Source #### Possible investigation steps @@ -77,22 +77,48 @@ timestamp_override = "event.ingested" type = "esql" query = ''' -FROM logs-*, .alerts-security.* -// query runs every 1 hour looking for activities occured during last 8 hours to match on disparate events -| where @timestamp > NOW() - 8 hours -// filter for Azure or M365 sign-in and External Alerts with source.ip not null -| where TO_IP(source.ip) is not null and (event.dataset in ("o365.audit", "azure.signinlogs") or kibana.alert.rule.name == "External Alerts") and -// exclude private IP ranges - not CIDR_MATCH(TO_IP(source.ip), "10.0.0.0/8", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.0.0.0/29", "192.0.0.8/32", "192.0.0.9/32", "192.0.0.10/32", "192.0.0.170/32", "192.0.0.171/32", "192.0.2.0/24", "192.31.196.0/24", "192.52.193.0/24", "192.168.0.0/16", "192.88.99.0/24", "224.0.0.0/4", "100.64.0.0/10", "192.175.48.0/24","198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "240.0.0.0/4", "::1","FE80::/10", "FF00::/8") +from logs-o365.audit-*, logs-azure.signinlogs-*, .alerts-security.* +// query runs every 1 hour looking for activities occurred during last 8 hours to match on disparate events +| where @timestamp > now() - 8 hours +// filter for azure or m365 sign-in and external alerts with source.ip not null +| where to_ip(source.ip) is not null + and (event.dataset in ("o365.audit", "azure.signinlogs") or kibana.alert.rule.name == "External Alerts") + and not cidr_match( + to_ip(source.ip), + "10.0.0.0/8", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.0.0.0/29", + "192.0.0.8/32", "192.0.0.9/32", "192.0.0.10/32", "192.0.0.170/32", "192.0.0.171/32", "192.0.2.0/24", + "192.31.196.0/24", "192.52.193.0/24", "192.168.0.0/16", "192.88.99.0/24", "224.0.0.0/4", + "100.64.0.0/10", "192.175.48.0/24", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", + "240.0.0.0/4", "::1", "FE80::/10", "FF00::/8" + ) + +// capture relevant raw fields | keep source.ip, event.action, event.outcome, event.dataset, kibana.alert.rule.name, event.category -// split alerts to 3 buckets - M365 mail access, azure sign-in and network related external alerts like NGFW and IDS -| eval mail_access_src_ip = case(event.dataset == "o365.audit" and event.action == "MailItemsAccessed" and event.outcome == "success", TO_IP(source.ip), null), - azure_src_ip = case(event.dataset == "azure.signinlogs" and event.outcome == "success", TO_IP(source.ip), null), - network_alert_src_ip = case(kibana.alert.rule.name == "External Alerts" and not event.dataset in ("o365.audit", "azure.signinlogs"), TO_IP(source.ip), null) -// aggregated alerts count by bucket and by source.ip -| stats total_alerts = count(*), is_mail_access = COUNT_DISTINCT(mail_access_src_ip), is_azure = COUNT_DISTINCT(azure_src_ip), unique_dataset = COUNT_DISTINCT(event.dataset),is_network_alert = COUNT_DISTINCT(network_alert_src_ip), datasets = VALUES(event.dataset), rules = VALUES(kibana.alert.rule.name), cat = VALUES(event.category) by source_ip = TO_IP(source.ip) -// filter for cases where there is a successful sign-in to azure or m365 mail and the source.ip is reported by a network external alert. -| where is_network_alert > 0 and unique_dataset >= 2 and (is_mail_access > 0 or is_azure > 0) and total_alerts <= 100 + +// classify each source ip based on alert type +| eval + Esql.source_ip_mail_access_case = case(event.dataset == "o365.audit" and event.action == "MailItemsAccessed" and event.outcome == "success", to_ip(source.ip), null), + Esql.source_ip_azure_signin_case = case(event.dataset == "azure.signinlogs" and event.outcome == "success", to_ip(source.ip), null), + Esql.source_ip_network_alert_case = case(kibana.alert.rule.name == "external alerts" and not event.dataset in ("o365.audit", "azure.signinlogs"), to_ip(source.ip), null) + +// aggregate by source ip +| stats + Esql.event_count = count(*), + Esql.source_ip_mail_access_case_count_distinct = count_distinct(Esql.source_ip_mail_access_case), + Esql.source_ip_azure_signin_case_count_distinct = count_distinct(Esql.source_ip_azure_signin_case), + Esql.source_ip_network_alert_case_count_distinct = count_distinct(Esql.source_ip_network_alert_case), + Esql.event_dataset_count_distinct = count_distinct(event.dataset), + Esql.event_dataset_values = values(event.dataset), + Esql.kibana_alert_rule_name_values = values(kibana.alert.rule.name), + Esql.event_category_values = values(event.category) + by Esql.source_ip = to_ip(source.ip) + +// correlation condition +| where + Esql.source_ip_network_alert_case_count_distinct > 0 + and Esql.event_dataset_count_distinct >= 2 + and (Esql.source_ip_mail_access_case_count_distinct > 0 or Esql.source_ip_azure_signin_case_count_distinct > 0) + and Esql.event_count <= 100 '''