[Rule Tuning] Microsoft Azure or Mail Sign-in from a Suspicious Source (#4946)

* change indices in ESQL query

* adjusted rule name
This commit is contained in:
Terrance DeJesus
2025-07-31 09:57:02 -04:00
committed by GitHub
parent 756a7f49ba
commit 0e78ce360b
@@ -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
'''