Detection rules › Splunk
Geographic Improbable Location
Geolocation data can be inaccurate or easily spoofed by Remote Employment Fraud (REF) workers. REF actors sometimes slip up and reveal their true location, creating what we call 'improbable travel' scenarios — logins from opposite sides of the world within minutes. This identifies situations where these travel scenarios occur.
Known false positives
- Legitimate usage of some VPNs may cause false positives. Tune as needed.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | |
| Persistence | |
| Privilege Escalation | |
| Stealth |
Rule body
name: Geographic Improbable Location
id: 64f91df1-49ec-46aa-81bd-2282d3cea765
version: 4
creation_date: '2025-06-12'
modification_date: '2026-05-13'
author: Marissa Bower, Raven Tait
status: experimental
type: Anomaly
description: Geolocation data can be inaccurate or easily spoofed by Remote Employment Fraud (REF) workers. REF actors sometimes slip up and reveal their true location, creating what we call 'improbable travel' scenarios — logins from opposite sides of the world within minutes. This identifies situations where these travel scenarios occur.
data_source:
- Okta
search: '| tstats summariesonly=true values(Authentication.app) as app from datamodel=Authentication.Authentication where (`okta` OR (index="firewall" AND sourcetype="pan:globalprotect")) AND Authentication.action="success" AND Authentication.app IN ("Workday", "Slack", "*GlobalProtect", "Jira*", "Atlassian Cloud", "Zoom") AND NOT Authentication.user="unknown" by _time index sourcetype host Authentication.user Authentication.src span=1s | `drop_dm_object_name("Authentication")` | fields user,src,app,_time,count,host | eval user=lower(replace(user, "((^.*\\\)|(@.*$))", "")) | join type=outer user [| inputlookup identity_lookup_expanded where user_status=active | rex field=email "^(?<user>[a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-\.]+)\.([a-zA-Z]{2,5})$" | rename email as user_email bunit as user_bunit priority as user_priority work_country as user_work_country work_city as user_work_city | fields user user_email user_bunit user_priority user_work_country user_work_city] | eventstats dc(src) as src_count by user | eventstats dc(user) as user_count by src | sort 0 + _time | iplocation src | lookup local=true asn_lookup_by_cidr ip as src OUTPUT ip asn description | eval session_lat=if(isnull(src_lat), lat, src_lat), session_lon=if(isnull(src_long), lon, src_long), session_city=if(isnull(src_city), City, src_city), session_country=if(isnull(src_country), Country, src_country), session_region=if(isnull(src_region), Region, src_region) | eval session_city=if(isnull(session_city) OR match(session_city,"^\s+|^$"), null(), session_city), session_country=if(isnull(session_country) OR match(session_country,"^\s+|^$"), null(), session_country), session_region=if(isnull(session_region) OR match(session_region,"^\s+|^$"), null(), session_region) | where isnotnull(session_lat) and isnotnull(session_lon) | eval session_city=if(isnull(session_city),"-",session_city), session_country=if(isnull(session_country),"-",session_country), session_region=if(isnull(session_region),"-",session_region) | streamstats current=t window=2 earliest(session_region) as prev_region,earliest(session_lat) as prev_lat, earliest(session_lon) as prev_lon, earliest(session_city) as prev_city, earliest(session_country) as prev_country, earliest(_time) as prev_time, earliest(src) as prev_src, latest(user_bunit) as user_bunit, earliest(app) as prev_app values(user_work_country) as user_work_country by user | where (src!=prev_src) AND !(prev_city=session_city AND prev_country=session_country) AND ((isnotnull(prev_city) AND isnotnull(session_city)) OR prev_country!=session_country) | `globedistance(session_lat,session_lon,prev_lat,prev_lon,"m")` | eval time_diff=if((_time-prev_time)==0, 1, _time - prev_time) | eval speed = round(distance*3600/time_diff,2) | eval distance= round(distance,2) | eval user_work_country=case(user_work_country="usa","United States", user_work_country="cze","Czechia", user_work_country="pol","Poland", user_work_country="ind","India", user_work_country="fra","France", user_work_country="can","Canada", user_work_country="mys","Malaysia", user_work_country="kor","South Korea", user_work_country="aus","Australia", user_work_country="bel","Belgium", user_work_country="dnk","Denmark", user_work_country="bra","Brazil", user_work_country="deu","Germany", user_work_country="jpn","Japan", user_work_country="che","Switzerland", user_work_country="swe","Sweden", user_work_country="zaf","South Africa", user_work_country="irl","Ireland", user_work_country="ita","Italy", user_work_country="nor","Norway", user_work_country="gbr","United Kingdom", user_work_country="hkg","Hong Kong", user_work_country="chn","China", user_work_country="esp","Spain", user_work_country="nld", "Netherlands", user_work_country="twn","Taiwan", user_work_country="est","Estonia", user_work_country="sgp","Singapore", user_work_country="are","United Arab Emirates", 1=1,"N/A") | lookup local=true asn_lookup_by_cidr ip as prev_src OUTPUT ip as prev_ip asn as prev_asn description as prev_description | eval suspect=if(!user_work_country==session_country,"Sketchy","Normal") | search (speed>500 AND distance>750) | table _time,prev_time,user,host,src,prev_src,app,prev_app,distance,speed,suspect,session_city,session_region, session_country,prev_city,prev_region,prev_country,user_priority,user_work_*,prev_ip,ip,asn,prev_asn,prev_description,description | rename _time as event_time | convert ctime(event_time) timeformat="%Y-%m-%d %H:%M:%S" | convert ctime(prev_time) timeformat="%Y-%m-%d %H:%M:%S" | eval problem=if(!session_country==prev_country AND (!session_country==user_work_country),"Yes","Nope") | search NOT (prev_city="-" OR session_city="-") AND NOT [inputlookup known_devices_public_ip_filter.csv | fields ip | rename ip as src] | dedup user host prev_src src | fillnull value="N/A" | search problem="Yes"| `geographic_improbable_location_filter`'
how_to_implement: The analytic leverages Okta OktaIm2 logs to be ingested using the Splunk Add-on for Okta Identity Cloud (https://splunkbase.splunk.com/app/6553). This also utilizes Splunk Enterprise Security Suite for several macros and lookups. The known_devices_public_ip_filter lookup is a placeholder for known public edge devices in your network.
known_false_positives: Legitimate usage of some VPNs may cause false positives. Tune as needed.
intermediate_findings:
entities:
- field: user
type: user
score: 20
message: Improbable travel speed between locations observed for $user$.
analytic_story:
- Remote Employment Fraud
asset_type: Identity
mitre_attack_id:
- T1078
product:
- Splunk Enterprise
- Splunk Enterprise Security
- Splunk Cloud
category: cloud
security_domain: identity
Stages and Predicates
Stage 1: tstats
| tstats summariesonly=true values(Authentication.app) as app from datamodel=Authentication.Authentication where (`okta` OR (index="firewall" AND sourcetype="pan:globalprotect")) AND Authentication.action="success" AND Authentication.app IN ("Workday", "Slack", "*GlobalProtect", "Jira*", "Atlassian Cloud", "Zoom") AND NOT Authentication.user="unknown" by _time index sourcetype host Authentication.user Authentication.src span=1s
Stage 2: search
| `drop_dm_object_name("Authentication")`
Stage 3: fields
| fields user,src,app,_time,count,host
Stage 4: eval
| eval user=lower(replace(user, "((^.*\\\)|(@.*$))", ""))
Stage 5: join
| join type=outer user [| inputlookup identity_lookup_expanded where user_status=active | rex field=email "^(?<user>[a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-\.]+)\.([a-zA-Z]{2,5})$" | rename email as user_email bunit as user_bunit priority as user_priority work_country as user_work_country work_city as user_work_city | fields user user_email user_bunit user_priority user_work_country user_work_city]
Stage 6: eventstats
| eventstats dc(src) as src_count by user
Stage 7: eventstats
| eventstats dc(user) as user_count by src
Stage 8: sort
| sort 0 + _time
Stage 9: search
| iplocation src
Stage 10: lookup
| lookup local=true asn_lookup_by_cidr ip as src OUTPUT ip asn description
Stage 11: eval
| eval session_lat=if(isnull(src_lat), lat, src_lat), session_lon=if(isnull(src_long), lon, src_long), session_city=if(isnull(src_city), City, src_city), session_country=if(isnull(src_country), Country, src_country), session_region=if(isnull(src_region), Region, src_region)
session_city =if
isnull(src_city)Cityelse
src_citysession_country =if
isnull(src_country)Countryelse
src_countrysession_lat =if
isnull(src_lat)latelse
src_latsession_lon =if
isnull(src_long)lonelse
src_longsession_region =if
isnull(src_region)Regionelse
src_regionStage 12: eval
| eval session_city=if(isnull(session_city) OR match(session_city,"^\s+|^$"), null(), session_city), session_country=if(isnull(session_country) OR match(session_country,"^\s+|^$"), null(), session_country), session_region=if(isnull(session_region) OR match(session_region,"^\s+|^$"), null(), session_region)
Stage 13: where
| where isnotnull(session_lat) and isnotnull(session_lon)
Stage 14: eval
| eval session_city=if(isnull(session_city),"-",session_city), session_country=if(isnull(session_country),"-",session_country), session_region=if(isnull(session_region),"-",session_region)
session_city =if
isnull(session_city)"-"else
session_citysession_country =if
isnull(session_country)"-"else
session_countrysession_region =if
isnull(session_region)"-"else
session_regionStage 15: streamstats
| streamstats current=t window=2 earliest(session_region) as prev_region,earliest(session_lat) as prev_lat, earliest(session_lon) as prev_lon, earliest(session_city) as prev_city, earliest(session_country) as prev_country, earliest(_time) as prev_time, earliest(src) as prev_src, latest(user_bunit) as user_bunit, earliest(app) as prev_app values(user_work_country) as user_work_country by user
Stage 16: where
| where (src!=prev_src) AND !(prev_city=session_city AND prev_country=session_country) AND ((isnotnull(prev_city) AND isnotnull(session_city)) OR prev_country!=session_country)
Stage 17: search
| `globedistance(session_lat,session_lon,prev_lat,prev_lon,"m")`
Stage 18: eval
| eval time_diff=if((_time-prev_time)==0, 1, _time - prev_time)
Stage 19: eval
| eval speed = round(distance*3600/time_diff,2)
Stage 20: eval
| eval distance= round(distance,2)
Stage 21: eval
| eval user_work_country=case(user_work_country="usa","United States", user_work_country="cze","Czechia", user_work_country="pol","Poland", user_work_country="ind","India", user_work_country="fra","France", user_work_country="can","Canada", user_work_country="mys","Malaysia", user_work_country="kor","South Korea", user_work_country="aus","Australia", user_work_country="bel","Belgium", user_work_country="dnk","Denmark", user_work_country="bra","Brazil", user_work_country="deu","Germany", user_work_country="jpn","Japan", user_work_country="che","Switzerland", user_work_country="swe","Sweden", user_work_country="zaf","South Africa", user_work_country="irl","Ireland", user_work_country="ita","Italy", user_work_country="nor","Norway", user_work_country="gbr","United Kingdom", user_work_country="hkg","Hong Kong", user_work_country="chn","China", user_work_country="esp","Spain", user_work_country="nld", "Netherlands", user_work_country="twn","Taiwan", user_work_country="est","Estonia", user_work_country="sgp","Singapore", user_work_country="are","United Arab Emirates", 1=1,"N/A")
user_work_country =if
user_work_country = "usa""United States"elif
user_work_country = "cze""Czechia"elif
user_work_country = "pol""Poland"elif
user_work_country = "ind""India"elif
user_work_country = "fra""France"elif
user_work_country = "can""Canada"elif
user_work_country = "mys""Malaysia"elif
user_work_country = "kor""South Korea"elif
user_work_country = "aus""Australia"elif
user_work_country = "bel""Belgium"elif
user_work_country = "dnk""Denmark"elif
user_work_country = "bra""Brazil"elif
user_work_country = "deu""Germany"elif
user_work_country = "jpn""Japan"elif
user_work_country = "che""Switzerland"elif
user_work_country = "swe""Sweden"elif
user_work_country = "zaf""South Africa"elif
user_work_country = "irl""Ireland"elif
user_work_country = "ita""Italy"elif
user_work_country = "nor""Norway"elif
user_work_country = "gbr""United Kingdom"elif
user_work_country = "hkg""Hong Kong"elif
user_work_country = "chn""China"elif
user_work_country = "esp""Spain"elif
user_work_country = "nld""Netherlands"elif
user_work_country = "twn""Taiwan"elif
user_work_country = "est""Estonia"elif
user_work_country = "sgp""Singapore"elif
user_work_country = "are""United Arab Emirates"else
"N/A"Stage 22: lookup
| lookup local=true asn_lookup_by_cidr ip as prev_src OUTPUT ip as prev_ip asn as prev_asn description as prev_description
Stage 23: eval
| eval suspect=if(!user_work_country==session_country,"Sketchy","Normal")
suspect =1.
"Sketchy"-
"Normal"Stage 24: search
| search (speed>500 AND distance>750)
Stage 25: table
| table _time,prev_time,user,host,src,prev_src,app,prev_app,distance,speed,suspect,session_city,session_region, session_country,prev_city,prev_region,prev_country,user_priority,user_work_*,prev_ip,ip,asn,prev_asn,prev_description,description
Stage 26: rename
| rename _time as event_time
Stage 27: convert
| convert ctime(event_time) timeformat="%Y-%m-%d %H:%M:%S"
Stage 28: convert
| convert ctime(prev_time) timeformat="%Y-%m-%d %H:%M:%S"
Stage 29: eval
| eval problem=if(!session_country==prev_country AND (!session_country==user_work_country),"Yes","Nope")
problem =1.
"Yes"-
"Nope"Stage 30: search
| search NOT (prev_city="-" OR session_city="-") AND NOT [inputlookup known_devices_public_ip_filter.csv | fields ip | rename ip as src]
Stage 31: dedup
| dedup user host prev_src src
Stage 32: fillnull
| fillnull value="N/A"
Stage 33: search
| search problem="Yes"
Stage 34: search
| `geographic_improbable_location_filter`
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Authentication.user | eq | "unknown" | excludes:Authentication.user |
prev_city | cross_field_compare | session_city | excludes:prev_city field:"prev_city" value:"session_city" |
prev_country | cross_field_compare | session_country | excludes:prev_country field:"prev_country" value:"session_country" |
prev_city | eq | "-" | excludes:prev_city |
session_city | eq | "-" | excludes:session_city |
1 | eq | 1 | excludes:1 field:"1" value:"1" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
Authentication.action | eq |
| field:"event_action" kind:eq |
Authentication.app | in |
| field:"app" kind:in |
distance | gt |
| field:"distance" kind:gt value:"750" |
index | eq |
| field:"index" kind:eq |
prev_city | is_not_null | field:"prev_city" kind:is_not_null | |
prev_country | cross_field_compare |
| field:"prev_country" kind:cross_field_compare value:"session_country" |
problem | eq |
| field:"problem" kind:eq |
session_city | is_not_null | field:"session_city" kind:is_not_null | |
session_lat | is_not_null | field:"session_lat" kind:is_not_null | |
session_lon | is_not_null | field:"session_lon" kind:is_not_null | |
sourcetype | eq |
| field:"sourcetype" kind:eq |
speed | gt |
| field:"speed" kind:gt value:"500" |
src | cross_field_compare |
| field:"src" kind:cross_field_compare value:"prev_src" |
Search terms
These SPL tokens match against raw event text.
| Stage | Term |
|---|---|
| 9 | iplocation |
| 9 | src |