Sample rules
Microsoft Entra ID Impossible Travel Sign-in
- source: elastic
- technicques:
- T1078
- T1528
- T1557
Description
Detects successful Microsoft Entra ID interactive sign-ins for the same user from two geographically separated locations within a 90-minute window, where the implied travel speed between the two points exceeds what is physically possible (>=800 km/h, faster than modern commercial airliners) and the geographic separation is at least 500 km. This pattern indicates either VPN/proxy use or an adversary signing in to a compromised account from a different location than the legitimate user. Non-interactive sign-in categories are excluded because backend token refresh activity routinely egresses through cloud regions unrelated to the user. This activity is often observed from AiTM phishing kits or successful phishing campaigns.
Detection logic
// successful interactive sign-ins with country + region populated.
from logs-azure.signinlogs-*
| where data_stream.dataset == "azure.signinlogs"
and event.outcome == "success"
and azure.signinlogs.category == "SignInLogs"
and azure.signinlogs.properties.user_principal_name is not null
and source.geo.location is not null
and source.geo.region_name is not null
| eval Esql.source_geo_lat = st_y(source.geo.location),
Esql.source_geo_lon = st_x(source.geo.location)
// collapse each (user, country, region) into one centroid + the actual lat/lon
// of the first and last event in that region. FIRST/LAST lock coords to the
// timestamp ordering so we can later build the honest event pair.
| stats
Esql.region_centroid_lat = avg(Esql.source_geo_lat),
Esql.region_centroid_lon = avg(Esql.source_geo_lon),
Esql.region_first_lat = first(Esql.source_geo_lat, @timestamp),
Esql.region_first_lon = first(Esql.source_geo_lon, @timestamp),
Esql.region_last_lat = last(Esql.source_geo_lat, @timestamp),
Esql.region_last_lon = last(Esql.source_geo_lon, @timestamp),
Esql.region_first_seen = min(@timestamp),
Esql.region_last_seen = max(@timestamp),
Esql.region_event_count = count(*),
Esql.region_city_values = values(source.geo.city_name),
Esql.region_asn_values = values(source.`as`.organization.name),
Esql.region_ip_values = values(source.ip),
Esql.region_ua_values = values(user_agent.original),
Esql.region_app_id_values = values(azure.signinlogs.properties.app_id),
Esql.region_app_display_name_values = values(azure.signinlogs.properties.app_display_name),
Esql.region_client_app_used_values = values(azure.signinlogs.properties.client_app_used),
Esql.region_resource_id_values = values(azure.signinlogs.properties.resource_id),
Esql.region_resource_display_name_values = values(azure.signinlogs.properties.resource_display_name),
Esql.region_browser_values = values(azure.signinlogs.properties.device_detail.browser),
Esql.region_os_values = values(azure.signinlogs.properties.device_detail.operating_system)
by azure.signinlogs.properties.user_principal_name,
source.geo.country_name,
source.geo.region_name
// roll up to the user. two parallel measurements:
// bbox: corners over region centroids. catches A->B->A because B is still
// a centroid in the set even when first/last events are in A.
| stats
Esql.min_lat = min(Esql.region_centroid_lat),
Esql.max_lat = max(Esql.region_centroid_lat),
Esql.min_lon = min(Esql.region_centroid_lon),
Esql.max_lon = max(Esql.region_centroid_lon),
Esql.honest_first_lat = first(Esql.region_first_lat, Esql.region_first_seen),
Esql.honest_first_lon = first(Esql.region_first_lon, Esql.region_first_seen),
Esql.honest_last_lat = last(Esql.region_last_lat, Esql.region_last_seen),
Esql.honest_last_lon = last(Esql.region_last_lon, Esql.region_last_seen),
Esql.timestamp_first_seen = min(Esql.region_first_seen),
Esql.timestamp_last_seen = max(Esql.region_first_seen), // first arrival in last region > tighter bbox window
Esql.honest_last_time = max(Esql.region_last_seen), // user's actual last event > honest window
Esql.region_count = count_distinct(source.geo.region_name),
Esql.country_count = count_distinct(source.geo.country_name),
Esql.event_count = sum(Esql.region_event_count),
Esql.source_geo_country_name_values = values(source.geo.country_name),
Esql.source_geo_region_name_values = values(source.geo.region_name),
Esql.source_geo_city_name_values = values(Esql.region_city_values),
Esql.source_as_organization_name_values = values(Esql.region_asn_values),
Esql.source_ip_values = values(Esql.region_ip_values),
Esql.user_agent_original_values = values(Esql.region_ua_values),
Esql.app_id_values = values(Esql.region_app_id_values),
Esql.app_display_name_values = values(Esql.region_app_display_name_values),
Esql.client_app_used_values = values(Esql.region_client_app_used_values),
Esql.resource_id_values = values(Esql.region_resource_id_values),
Esql.resource_display_name_values = values(Esql.region_resource_display_name_values),
Esql.device_detail_browser_values = values(Esql.region_browser_values),
Esql.device_detail_operating_system_values = values(Esql.region_os_values)
by azure.signinlogs.properties.user_principal_name
// need at least 2 regions to have anything to compare. cap at 5 because regions
// are finer-grained than countries (a traveling employee can hit 3-4 in 90m via
// carrier hub bouncing) > bbox drift stays bounded below this.
| where Esql.region_count >= 2 and Esql.region_count <= 5
// bbox path (primary trigger): corners over region centroids.
| eval Esql.p1 = to_geopoint(concat("POINT(", to_string(Esql.min_lon), " ", to_string(Esql.min_lat), ")")),
Esql.p2 = to_geopoint(concat("POINT(", to_string(Esql.max_lon), " ", to_string(Esql.max_lat), ")"))
| eval Esql.distance_km = round(st_distance(Esql.p1, Esql.p2) / 1000.0, 0),
Esql.window_minutes = date_diff("minute", Esql.timestamp_first_seen, Esql.timestamp_last_seen),
Esql.travel_kmh = case(Esql.window_minutes > 0,
round(Esql.distance_km * 60.0 / Esql.window_minutes, 0), null)
// honest pair (triage signal): real coords at the user's actual first and last
// events, time locked to those same two events
| eval Esql.honest_p1 = to_geopoint(concat("POINT(", to_string(Esql.honest_first_lon), " ", to_string(Esql.honest_first_lat), ")")),
Esql.honest_p2 = to_geopoint(concat("POINT(", to_string(Esql.honest_last_lon), " ", to_string(Esql.honest_last_lat), ")"))
| eval Esql.honest_distance_km = round(st_distance(Esql.honest_p1, Esql.honest_p2) / 1000.0, 0),
Esql.honest_window_minutes = date_diff("minute", Esql.timestamp_first_seen, Esql.honest_last_time),
Esql.honest_travel_kmh = case(Esql.honest_window_minutes > 0,
round(Esql.honest_distance_km * 60.0 / Esql.honest_window_minutes, 0), null)
// 500 km separation + faster than a commercial airliner. bbox is the trigger
// purely as triage signal.
| where Esql.distance_km >= 500 and Esql.travel_kmh >= 800
| keep azure.signinlogs.properties.user_principal_name,
Esql.source_geo_country_name_values,
Esql.source_geo_region_name_values,
Esql.source_geo_city_name_values,
Esql.source_as_organization_name_values,
Esql.source_ip_values,
Esql.user_agent_original_values,
Esql.app_id_values,
Esql.app_display_name_values,
Esql.client_app_used_values,
Esql.resource_id_values,
Esql.resource_display_name_values,
Esql.device_detail_browser_values,
Esql.device_detail_operating_system_values,
Esql.country_count,
Esql.region_count,
Esql.event_count,
Esql.timestamp_first_seen,
Esql.timestamp_last_seen,
Esql.window_minutes,
Esql.distance_km,
Esql.travel_kmh,
Esql.honest_distance_km,
Esql.honest_travel_kmh,
Esql.honest_window_minutes
Google Workspace Impossible Travel Login
- source: elastic
- technicques:
- T1078
- T1528
- T1557
Description
Detects successful Google Workspace sign-ins for the same user from two geographically separated locations within a 90-minute window, where the implied travel speed between the two points exceeds what is physically possible (>=800 km/h, faster than modern commercial airliners) and the geographic separation is at least 500 km. This pattern indicates either VPN/proxy use or an adversary signing in to a compromised account from a different location than the legitimate user.
Detection logic
// successful Google Workspace logins with country + region populated.
from logs-google_workspace.login-*
| where event.dataset == "google_workspace.login"
and event.action == "login_success"
and event.outcome == "success"
and user.email is not null
and source.geo.location is not null
and source.geo.country_name is not null
and source.geo.region_name is not null
| eval Esql.source_geo_lat = st_y(source.geo.location),
Esql.source_geo_lon = st_x(source.geo.location)
// collapse each (user, country, region) into one centroid + the actual lat/lon
// of the first and last event in that region. FIRST/LAST lock coords to the
// timestamp ordering so we can later build the honest event pair.
| stats
Esql.region_centroid_lat = avg(Esql.source_geo_lat),
Esql.region_centroid_lon = avg(Esql.source_geo_lon),
Esql.region_first_lat = first(Esql.source_geo_lat, @timestamp),
Esql.region_first_lon = first(Esql.source_geo_lon, @timestamp),
Esql.region_last_lat = last(Esql.source_geo_lat, @timestamp),
Esql.region_last_lon = last(Esql.source_geo_lon, @timestamp),
Esql.region_first_seen = min(@timestamp),
Esql.region_last_seen = max(@timestamp),
Esql.region_event_count = count(*),
Esql.region_city_values = values(source.geo.city_name),
Esql.region_asn_values = values(source.`as`.organization.name),
Esql.region_ip_values = values(source.ip)
by user.email,
source.geo.country_name,
source.geo.region_name
// roll up to the user. two parallel measurements:
// bbox: corners over region centroids.
// honest: real coords at the user's actual first and last events (nested FIRST/LAST).
| stats
Esql.min_lat = min(Esql.region_centroid_lat),
Esql.max_lat = max(Esql.region_centroid_lat),
Esql.min_lon = min(Esql.region_centroid_lon),
Esql.max_lon = max(Esql.region_centroid_lon),
Esql.honest_first_lat = first(Esql.region_first_lat, Esql.region_first_seen),
Esql.honest_first_lon = first(Esql.region_first_lon, Esql.region_first_seen),
Esql.honest_last_lat = last(Esql.region_last_lat, Esql.region_last_seen),
Esql.honest_last_lon = last(Esql.region_last_lon, Esql.region_last_seen),
Esql.timestamp_first_seen = min(Esql.region_first_seen),
Esql.timestamp_last_seen = max(Esql.region_first_seen), // first arrival in last region > tighter bbox window
Esql.honest_last_time = max(Esql.region_last_seen), // user's actual last event > honest window
Esql.region_count = count_distinct(source.geo.region_name),
Esql.country_count = count_distinct(source.geo.country_name),
Esql.event_count = sum(Esql.region_event_count),
Esql.source_geo_country_name_values = values(source.geo.country_name),
Esql.source_geo_region_name_values = values(source.geo.region_name),
Esql.source_geo_city_name_values = values(Esql.region_city_values),
Esql.source_as_organization_name_values = values(Esql.region_asn_values),
Esql.source_ip_values = values(Esql.region_ip_values)
by user.email
// need at least 2 regions to have anything to compare. cap at 5 because regions
// are finer-grained than countries (a traveling user can hit 3-4 in 90m via
// carrier hub bouncing) > bbox drift stays bounded below this.
| where Esql.region_count >= 2 and Esql.region_count <= 5
// bbox path (primary trigger): corners over region centroids.
| eval Esql.p1 = to_geopoint(concat("POINT(", to_string(Esql.min_lon), " ", to_string(Esql.min_lat), ")")),
Esql.p2 = to_geopoint(concat("POINT(", to_string(Esql.max_lon), " ", to_string(Esql.max_lat), ")"))
| eval Esql.distance_km = round(st_distance(Esql.p1, Esql.p2) / 1000.0, 0),
Esql.window_minutes = date_diff("minute", Esql.timestamp_first_seen, Esql.timestamp_last_seen),
Esql.travel_kmh = case(Esql.window_minutes > 0,
round(Esql.distance_km * 60.0 / Esql.window_minutes, 0), null)
// honest pair (triage signal): real coords at the user's actual first and last
// events, time locked to those same two events.
| eval Esql.honest_p1 = to_geopoint(concat("POINT(", to_string(Esql.honest_first_lon), " ", to_string(Esql.honest_first_lat), ")")),
Esql.honest_p2 = to_geopoint(concat("POINT(", to_string(Esql.honest_last_lon), " ", to_string(Esql.honest_last_lat), ")"))
| eval Esql.honest_distance_km = round(st_distance(Esql.honest_p1, Esql.honest_p2) / 1000.0, 0),
Esql.honest_window_minutes = date_diff("minute", Esql.timestamp_first_seen, Esql.honest_last_time),
Esql.honest_travel_kmh = case(Esql.honest_window_minutes > 0,
round(Esql.honest_distance_km * 60.0 / Esql.honest_window_minutes, 0), null)
// 500 km separation + faster than a commercial airliner. bbox is the trigger
// honest fields are kept purely as triage signal.
| where Esql.distance_km >= 500 and Esql.travel_kmh >= 800
| keep user.email,
Esql.source_geo_country_name_values,
Esql.source_geo_region_name_values,
Esql.source_geo_city_name_values,
Esql.source_as_organization_name_values,
Esql.source_ip_values,
Esql.country_count,
Esql.region_count,
Esql.event_count,
Esql.timestamp_first_seen,
Esql.timestamp_last_seen,
Esql.window_minutes,
Esql.distance_km,
Esql.travel_kmh,
Esql.honest_distance_km,
Esql.honest_travel_kmh,
Esql.honest_window_minutes
Sample rules
Microsoft Entra ID Impossible Travel Sign-in
- source: elastic
- technicques:
- T1078
- T1528
- T1557
Description
Detects successful Microsoft Entra ID interactive sign-ins for the same user from two geographically separated locations within a 90-minute window, where the implied travel speed between the two points exceeds what is physically possible (>=800 km/h, faster than modern commercial airliners) and the geographic separation is at least 500 km. This pattern indicates either VPN/proxy use or an adversary signing in to a compromised account from a different location than the legitimate user. Non-interactive sign-in categories are excluded because backend token refresh activity routinely egresses through cloud regions unrelated to the user. This activity is often observed from AiTM phishing kits or successful phishing campaigns.
Detection logic
// successful interactive sign-ins with country + region populated.
from logs-azure.signinlogs-*
| where data_stream.dataset == "azure.signinlogs"
and event.outcome == "success"
and azure.signinlogs.category == "SignInLogs"
and azure.signinlogs.properties.user_principal_name is not null
and source.geo.location is not null
and source.geo.region_name is not null
| eval Esql.source_geo_lat = st_y(source.geo.location),
Esql.source_geo_lon = st_x(source.geo.location)
// collapse each (user, country, region) into one centroid + the actual lat/lon
// of the first and last event in that region. FIRST/LAST lock coords to the
// timestamp ordering so we can later build the honest event pair.
| stats
Esql.region_centroid_lat = avg(Esql.source_geo_lat),
Esql.region_centroid_lon = avg(Esql.source_geo_lon),
Esql.region_first_lat = first(Esql.source_geo_lat, @timestamp),
Esql.region_first_lon = first(Esql.source_geo_lon, @timestamp),
Esql.region_last_lat = last(Esql.source_geo_lat, @timestamp),
Esql.region_last_lon = last(Esql.source_geo_lon, @timestamp),
Esql.region_first_seen = min(@timestamp),
Esql.region_last_seen = max(@timestamp),
Esql.region_event_count = count(*),
Esql.region_city_values = values(source.geo.city_name),
Esql.region_asn_values = values(source.`as`.organization.name),
Esql.region_ip_values = values(source.ip),
Esql.region_ua_values = values(user_agent.original),
Esql.region_app_id_values = values(azure.signinlogs.properties.app_id),
Esql.region_app_display_name_values = values(azure.signinlogs.properties.app_display_name),
Esql.region_client_app_used_values = values(azure.signinlogs.properties.client_app_used),
Esql.region_resource_id_values = values(azure.signinlogs.properties.resource_id),
Esql.region_resource_display_name_values = values(azure.signinlogs.properties.resource_display_name),
Esql.region_browser_values = values(azure.signinlogs.properties.device_detail.browser),
Esql.region_os_values = values(azure.signinlogs.properties.device_detail.operating_system)
by azure.signinlogs.properties.user_principal_name,
source.geo.country_name,
source.geo.region_name
// roll up to the user. two parallel measurements:
// bbox: corners over region centroids. catches A->B->A because B is still
// a centroid in the set even when first/last events are in A.
| stats
Esql.min_lat = min(Esql.region_centroid_lat),
Esql.max_lat = max(Esql.region_centroid_lat),
Esql.min_lon = min(Esql.region_centroid_lon),
Esql.max_lon = max(Esql.region_centroid_lon),
Esql.honest_first_lat = first(Esql.region_first_lat, Esql.region_first_seen),
Esql.honest_first_lon = first(Esql.region_first_lon, Esql.region_first_seen),
Esql.honest_last_lat = last(Esql.region_last_lat, Esql.region_last_seen),
Esql.honest_last_lon = last(Esql.region_last_lon, Esql.region_last_seen),
Esql.timestamp_first_seen = min(Esql.region_first_seen),
Esql.timestamp_last_seen = max(Esql.region_first_seen), // first arrival in last region > tighter bbox window
Esql.honest_last_time = max(Esql.region_last_seen), // user's actual last event > honest window
Esql.region_count = count_distinct(source.geo.region_name),
Esql.country_count = count_distinct(source.geo.country_name),
Esql.event_count = sum(Esql.region_event_count),
Esql.source_geo_country_name_values = values(source.geo.country_name),
Esql.source_geo_region_name_values = values(source.geo.region_name),
Esql.source_geo_city_name_values = values(Esql.region_city_values),
Esql.source_as_organization_name_values = values(Esql.region_asn_values),
Esql.source_ip_values = values(Esql.region_ip_values),
Esql.user_agent_original_values = values(Esql.region_ua_values),
Esql.app_id_values = values(Esql.region_app_id_values),
Esql.app_display_name_values = values(Esql.region_app_display_name_values),
Esql.client_app_used_values = values(Esql.region_client_app_used_values),
Esql.resource_id_values = values(Esql.region_resource_id_values),
Esql.resource_display_name_values = values(Esql.region_resource_display_name_values),
Esql.device_detail_browser_values = values(Esql.region_browser_values),
Esql.device_detail_operating_system_values = values(Esql.region_os_values)
by azure.signinlogs.properties.user_principal_name
// need at least 2 regions to have anything to compare. cap at 5 because regions
// are finer-grained than countries (a traveling employee can hit 3-4 in 90m via
// carrier hub bouncing) > bbox drift stays bounded below this.
| where Esql.region_count >= 2 and Esql.region_count <= 5
// bbox path (primary trigger): corners over region centroids.
| eval Esql.p1 = to_geopoint(concat("POINT(", to_string(Esql.min_lon), " ", to_string(Esql.min_lat), ")")),
Esql.p2 = to_geopoint(concat("POINT(", to_string(Esql.max_lon), " ", to_string(Esql.max_lat), ")"))
| eval Esql.distance_km = round(st_distance(Esql.p1, Esql.p2) / 1000.0, 0),
Esql.window_minutes = date_diff("minute", Esql.timestamp_first_seen, Esql.timestamp_last_seen),
Esql.travel_kmh = case(Esql.window_minutes > 0,
round(Esql.distance_km * 60.0 / Esql.window_minutes, 0), null)
// honest pair (triage signal): real coords at the user's actual first and last
// events, time locked to those same two events
| eval Esql.honest_p1 = to_geopoint(concat("POINT(", to_string(Esql.honest_first_lon), " ", to_string(Esql.honest_first_lat), ")")),
Esql.honest_p2 = to_geopoint(concat("POINT(", to_string(Esql.honest_last_lon), " ", to_string(Esql.honest_last_lat), ")"))
| eval Esql.honest_distance_km = round(st_distance(Esql.honest_p1, Esql.honest_p2) / 1000.0, 0),
Esql.honest_window_minutes = date_diff("minute", Esql.timestamp_first_seen, Esql.honest_last_time),
Esql.honest_travel_kmh = case(Esql.honest_window_minutes > 0,
round(Esql.honest_distance_km * 60.0 / Esql.honest_window_minutes, 0), null)
// 500 km separation + faster than a commercial airliner. bbox is the trigger
// purely as triage signal.
| where Esql.distance_km >= 500 and Esql.travel_kmh >= 800
| keep azure.signinlogs.properties.user_principal_name,
Esql.source_geo_country_name_values,
Esql.source_geo_region_name_values,
Esql.source_geo_city_name_values,
Esql.source_as_organization_name_values,
Esql.source_ip_values,
Esql.user_agent_original_values,
Esql.app_id_values,
Esql.app_display_name_values,
Esql.client_app_used_values,
Esql.resource_id_values,
Esql.resource_display_name_values,
Esql.device_detail_browser_values,
Esql.device_detail_operating_system_values,
Esql.country_count,
Esql.region_count,
Esql.event_count,
Esql.timestamp_first_seen,
Esql.timestamp_last_seen,
Esql.window_minutes,
Esql.distance_km,
Esql.travel_kmh,
Esql.honest_distance_km,
Esql.honest_travel_kmh,
Esql.honest_window_minutes
Google Workspace Impossible Travel Login
- source: elastic
- technicques:
- T1078
- T1528
- T1557
Description
Detects successful Google Workspace sign-ins for the same user from two geographically separated locations within a 90-minute window, where the implied travel speed between the two points exceeds what is physically possible (>=800 km/h, faster than modern commercial airliners) and the geographic separation is at least 500 km. This pattern indicates either VPN/proxy use or an adversary signing in to a compromised account from a different location than the legitimate user.
Detection logic
// successful Google Workspace logins with country + region populated.
from logs-google_workspace.login-*
| where event.dataset == "google_workspace.login"
and event.action == "login_success"
and event.outcome == "success"
and user.email is not null
and source.geo.location is not null
and source.geo.country_name is not null
and source.geo.region_name is not null
| eval Esql.source_geo_lat = st_y(source.geo.location),
Esql.source_geo_lon = st_x(source.geo.location)
// collapse each (user, country, region) into one centroid + the actual lat/lon
// of the first and last event in that region. FIRST/LAST lock coords to the
// timestamp ordering so we can later build the honest event pair.
| stats
Esql.region_centroid_lat = avg(Esql.source_geo_lat),
Esql.region_centroid_lon = avg(Esql.source_geo_lon),
Esql.region_first_lat = first(Esql.source_geo_lat, @timestamp),
Esql.region_first_lon = first(Esql.source_geo_lon, @timestamp),
Esql.region_last_lat = last(Esql.source_geo_lat, @timestamp),
Esql.region_last_lon = last(Esql.source_geo_lon, @timestamp),
Esql.region_first_seen = min(@timestamp),
Esql.region_last_seen = max(@timestamp),
Esql.region_event_count = count(*),
Esql.region_city_values = values(source.geo.city_name),
Esql.region_asn_values = values(source.`as`.organization.name),
Esql.region_ip_values = values(source.ip)
by user.email,
source.geo.country_name,
source.geo.region_name
// roll up to the user. two parallel measurements:
// bbox: corners over region centroids.
// honest: real coords at the user's actual first and last events (nested FIRST/LAST).
| stats
Esql.min_lat = min(Esql.region_centroid_lat),
Esql.max_lat = max(Esql.region_centroid_lat),
Esql.min_lon = min(Esql.region_centroid_lon),
Esql.max_lon = max(Esql.region_centroid_lon),
Esql.honest_first_lat = first(Esql.region_first_lat, Esql.region_first_seen),
Esql.honest_first_lon = first(Esql.region_first_lon, Esql.region_first_seen),
Esql.honest_last_lat = last(Esql.region_last_lat, Esql.region_last_seen),
Esql.honest_last_lon = last(Esql.region_last_lon, Esql.region_last_seen),
Esql.timestamp_first_seen = min(Esql.region_first_seen),
Esql.timestamp_last_seen = max(Esql.region_first_seen), // first arrival in last region > tighter bbox window
Esql.honest_last_time = max(Esql.region_last_seen), // user's actual last event > honest window
Esql.region_count = count_distinct(source.geo.region_name),
Esql.country_count = count_distinct(source.geo.country_name),
Esql.event_count = sum(Esql.region_event_count),
Esql.source_geo_country_name_values = values(source.geo.country_name),
Esql.source_geo_region_name_values = values(source.geo.region_name),
Esql.source_geo_city_name_values = values(Esql.region_city_values),
Esql.source_as_organization_name_values = values(Esql.region_asn_values),
Esql.source_ip_values = values(Esql.region_ip_values)
by user.email
// need at least 2 regions to have anything to compare. cap at 5 because regions
// are finer-grained than countries (a traveling user can hit 3-4 in 90m via
// carrier hub bouncing) > bbox drift stays bounded below this.
| where Esql.region_count >= 2 and Esql.region_count <= 5
// bbox path (primary trigger): corners over region centroids.
| eval Esql.p1 = to_geopoint(concat("POINT(", to_string(Esql.min_lon), " ", to_string(Esql.min_lat), ")")),
Esql.p2 = to_geopoint(concat("POINT(", to_string(Esql.max_lon), " ", to_string(Esql.max_lat), ")"))
| eval Esql.distance_km = round(st_distance(Esql.p1, Esql.p2) / 1000.0, 0),
Esql.window_minutes = date_diff("minute", Esql.timestamp_first_seen, Esql.timestamp_last_seen),
Esql.travel_kmh = case(Esql.window_minutes > 0,
round(Esql.distance_km * 60.0 / Esql.window_minutes, 0), null)
// honest pair (triage signal): real coords at the user's actual first and last
// events, time locked to those same two events.
| eval Esql.honest_p1 = to_geopoint(concat("POINT(", to_string(Esql.honest_first_lon), " ", to_string(Esql.honest_first_lat), ")")),
Esql.honest_p2 = to_geopoint(concat("POINT(", to_string(Esql.honest_last_lon), " ", to_string(Esql.honest_last_lat), ")"))
| eval Esql.honest_distance_km = round(st_distance(Esql.honest_p1, Esql.honest_p2) / 1000.0, 0),
Esql.honest_window_minutes = date_diff("minute", Esql.timestamp_first_seen, Esql.honest_last_time),
Esql.honest_travel_kmh = case(Esql.honest_window_minutes > 0,
round(Esql.honest_distance_km * 60.0 / Esql.honest_window_minutes, 0), null)
// 500 km separation + faster than a commercial airliner. bbox is the trigger
// honest fields are kept purely as triage signal.
| where Esql.distance_km >= 500 and Esql.travel_kmh >= 800
| keep user.email,
Esql.source_geo_country_name_values,
Esql.source_geo_region_name_values,
Esql.source_geo_city_name_values,
Esql.source_as_organization_name_values,
Esql.source_ip_values,
Esql.country_count,
Esql.region_count,
Esql.event_count,
Esql.timestamp_first_seen,
Esql.timestamp_last_seen,
Esql.window_minutes,
Esql.distance_km,
Esql.travel_kmh,
Esql.honest_distance_km,
Esql.honest_travel_kmh,
Esql.honest_window_minutes
Anthropic Impossible Travel Login
- source: elastic
- technicques:
- T1078
Description
Detects successful Anthropic magic link or SSO sign-ins for the same user email from source IP addresses whose query-time geo-locations (via IP_LOCATION) are separated by at least 1,000 km, with implied travel faster than 800 km/h, within a 24-hour window. That pattern can indicate account sharing, VPN or proxy egress mismatches, or an adversary authenticating from a geography far from the legitimate user’s baseline.
Detection logic
from logs-anthropic.audit-*
| where
data_stream.dataset == "anthropic.audit" and
mv_contains(event.category, "authentication") and
event.outcome == "success" and
event.action in ("magic_link_login_succeeded", "sso_login_succeeded") and
user.email is not null and
source.ip is not null
| IP_LOCATION geo = source.ip with { "properties": ["country_name", "city_name", "location"] }
| eval
Esql.source_geo_lat = st_y(geo.location),
Esql.source_geo_lon = st_x(geo.location)
| where Esql.source_geo_lat is not null and Esql.source_geo_lon is not null
| stats
Esql.first_lat = first(Esql.source_geo_lat, @timestamp),
Esql.first_lon = first(Esql.source_geo_lon, @timestamp),
Esql.last_lat = last(Esql.source_geo_lat, @timestamp),
Esql.last_lon = last(Esql.source_geo_lon, @timestamp),
Esql.event_count = count(*),
Esql.event_id_values = values(event.id),
Esql.event_action_values = values(event.action),
Esql.source_ip_values = values(source.ip),
Esql.source_geo_country_name_values = values(geo.country_name),
Esql.source_geo_city_name_values = values(geo.city_name),
Esql.user_agent_original_values = values(user_agent.original),
Esql.anthropic_audit_actor_type_values = values(anthropic.audit.actor.type),
Esql.timestamp_first_seen = min(@timestamp),
Esql.timestamp_last_seen = max(@timestamp)
by user.email
| where Esql.event_count >= 2
| eval
Esql.p1 = to_geopoint(concat("POINT(", to_string(Esql.first_lon), " ", to_string(Esql.first_lat), ")")),
Esql.p2 = to_geopoint(concat("POINT(", to_string(Esql.last_lon), " ", to_string(Esql.last_lat), ")"))
| eval
Esql.distance_km = round(st_distance(Esql.p1, Esql.p2) / 1000.0, 0),
Esql.window_minutes = date_diff("minute", Esql.timestamp_first_seen, Esql.timestamp_last_seen),
Esql.travel_kmh = case(Esql.window_minutes > 0, round(Esql.distance_km * 60.0 / Esql.window_minutes, 0), null)
| where Esql.distance_km >= 1000 and Esql.travel_kmh >= 800
| keep user.email, Esql.*
Anthropic Session Reuse Impossible Travel
- source: elastic
- technicques:
- T1078
- T1539
Description
Detects successful Anthropic audit activity for the same user email from source IP addresses whose query-time geo-locations (via IP_LOCATION) span at least two countries, are separated by at least 500 km, and imply travel faster than 800 km/h within a short (~15-minute) lookback. Unlike login-only impossible travel, this rule covers any successful user-actor activity and can surface session cookie replay or concurrent session reuse when no new authentication events appear.
Detection logic
from logs-anthropic.audit-*
| where
data_stream.dataset == "anthropic.audit" and
event.outcome == "success" and
anthropic.audit.actor.type == "user_actor" and
user.email is not null and
source.ip is not null
| IP_LOCATION geo = source.ip with { "properties": ["country_name", "city_name", "location"] }
| eval
Esql.source_geo_lat = st_y(geo.location),
Esql.source_geo_lon = st_x(geo.location)
| where Esql.source_geo_lat is not null and Esql.source_geo_lon is not null
| stats
Esql.first_lat = first(Esql.source_geo_lat, @timestamp),
Esql.first_lon = first(Esql.source_geo_lon, @timestamp),
Esql.last_lat = last(Esql.source_geo_lat, @timestamp),
Esql.last_lon = last(Esql.source_geo_lon, @timestamp),
Esql.event_count = count(*),
Esql.country_count = count_distinct(geo.country_name),
Esql.event_id_values = values(event.id),
Esql.event_action_values = values(event.action),
Esql.source_ip_values = values(source.ip),
Esql.source_geo_country_name_values = values(geo.country_name),
Esql.source_geo_city_name_values = values(geo.city_name),
Esql.user_agent_original_values = values(user_agent.original),
Esql.anthropic_audit_actor_type_values = values(anthropic.audit.actor.type),
Esql.timestamp_first_seen = min(@timestamp),
Esql.timestamp_last_seen = max(@timestamp)
by user.email
| where Esql.event_count >= 2 and Esql.country_count >= 2
| eval
Esql.p1 = to_geopoint(concat("POINT(", to_string(Esql.first_lon), " ", to_string(Esql.first_lat), ")")),
Esql.p2 = to_geopoint(concat("POINT(", to_string(Esql.last_lon), " ", to_string(Esql.last_lat), ")"))
| eval
Esql.distance_km = round(st_distance(Esql.p1, Esql.p2) / 1000.0, 0),
Esql.window_minutes = date_diff("minute", Esql.timestamp_first_seen, Esql.timestamp_last_seen),
Esql.travel_kmh = case(Esql.window_minutes > 0, round(Esql.distance_km * 60.0 / Esql.window_minutes, 0), null)
| where Esql.distance_km >= 500 and Esql.travel_kmh >= 800
| keep user.email, Esql.*