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.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Initial Access | T1078 Valid Accounts |
| Persistence | T1078 Valid Accounts |
| Privilege Escalation | T1078 Valid Accounts |
| Stealth | T1078 Valid Accounts |
Rule body splunk
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.
drilldown_searches:
- name: View the detection results for - "$user$"
search: '%original_detection_search% | search Authentication.user = "$user$"'
earliest_offset: $info_min_time$
latest_offset: $info_max_time$
- name: View risk events for the last 7 days for - "$user$"
search: '| from datamodel Risk.All_Risk | search normalized_risk_object IN ("$user$") | stats count min(_time) as firstTime max(_time) as lastTime values(search_name) as "Search Name" values(risk_message) as "Risk Message" values(analyticstories) as "Analytic Stories" values(annotations._all) as "Annotations" values(annotations.mitre_attack.mitre_tactic) as "ATT&CK Tactics" by normalized_risk_object | `security_content_ctime(firstTime)` | `security_content_ctime(lastTime)`'
earliest_offset: 7d
latest_offset: "0"
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 =isnull(src_city)Citysrc_citysession_country =isnull(src_country)Countrysrc_countrysession_lat =isnull(src_lat)latsrc_latsession_lon =isnull(src_long)lonsrc_longsession_region =isnull(src_region)Regionsrc_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 =isnull(session_city)"-"session_citysession_country =isnull(session_country)"-"session_countrysession_region =isnull(session_region)"-"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 =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""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 ="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 ="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
Top-level NOT(...) conjuncts: predicates this rule actively suppresses.
| Field | Kind | Excluded values |
|---|---|---|
Authentication.user | eq | "unknown" |
prev_city | eq | |
prev_country | eq | |
prev_city | eq | "-" |
session_city | eq | "-" |
1 | eq | 1 |
Indicators
Each row is a field, operator, and value that the rule matches. The corpus column counts how many other rules in the catalog look for the same combination: high numbers point to widely-used, community-vetted indicators. Blank or 1 shows that the indicator is specific to this rule.
| Field | Kind | Values |
|---|---|---|
Authentication.action | eq |
|
Authentication.app | in |
|
distance | gt |
|
index | eq |
|
prev_city | is_not_null | |
problem | eq |
|
session_city | is_not_null | |
session_lat | is_not_null | |
session_lon | is_not_null | |
sourcetype | eq |
|
speed | gt |
|
Search terms
Bare-string tokens in the SPL search body. Splunk matches each token against _raw (the untyped raw event text) anywhere it appears, not against a specific field. These don't surface in the Indicators table because they aren't predicates on a known field.
| Stage | Term |
|---|---|
| 9 | iplocation |
| 9 | src |