Elastic high stable kql
Google Workspace Impossible Travel Login
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 False Positives
- ⚠ Users on VPN or proxy egress that geo-resolves through a region distant from the user's physical location. Mobile clients on cellular carrier networks that peer through regional hubs may geo-resolve to a different region than the user's physical location.
Field Validations
Loading…
Comments (0)
Loading comments...