Detection rules › Splunk

Geographic Improbable Location

Status
experimental
Severity
low
Group by
_time, host, index, sourcetype, src, user
Author
Marissa Bower, Raven Tait
Source
github.com/splunk/security_content

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

TacticTechniques
Initial AccessT1078 Valid Accounts
PersistenceT1078 Valid Accounts
Privilege EscalationT1078 Valid Accounts
StealthT1078 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
Lookup table
asn_lookup_by_cidr
Key field
ip as src
Output columns
['ip', 'ip'], ['asn', 'asn'], ['description', '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 =
ifisnull(src_city)City
elsesrc_city
session_country =
ifisnull(src_country)Country
elsesrc_country
session_lat =
ifisnull(src_lat)lat
elsesrc_lat
session_lon =
ifisnull(src_long)lon
elsesrc_long
session_region =
ifisnull(src_region)Region
elsesrc_region

Stage 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 =
ifisnull(session_city)"-"
elsesession_city
session_country =
ifisnull(session_country)"-"
elsesession_country
session_region =
ifisnull(session_region)"-"
elsesession_region

Stage 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 =
ifuser_work_country = "usa""United States"
elifuser_work_country = "cze""Czechia"
elifuser_work_country = "pol""Poland"
elifuser_work_country = "ind""India"
elifuser_work_country = "fra""France"
elifuser_work_country = "can""Canada"
elifuser_work_country = "mys""Malaysia"
elifuser_work_country = "kor""South Korea"
elifuser_work_country = "aus""Australia"
elifuser_work_country = "bel""Belgium"
elifuser_work_country = "dnk""Denmark"
elifuser_work_country = "bra""Brazil"
elifuser_work_country = "deu""Germany"
elifuser_work_country = "jpn""Japan"
elifuser_work_country = "che""Switzerland"
elifuser_work_country = "swe""Sweden"
elifuser_work_country = "zaf""South Africa"
elifuser_work_country = "irl""Ireland"
elifuser_work_country = "ita""Italy"
elifuser_work_country = "nor""Norway"
elifuser_work_country = "gbr""United Kingdom"
elifuser_work_country = "hkg""Hong Kong"
elifuser_work_country = "chn""China"
elifuser_work_country = "esp""Spain"
elifuser_work_country = "nld""Netherlands"
elifuser_work_country = "twn""Taiwan"
elifuser_work_country = "est""Estonia"
elifuser_work_country = "sgp""Singapore"
elifuser_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
Lookup table
asn_lookup_by_cidr
Key field
ip as prev_src
Output columns
['ip', 'prev_ip'], ['asn', 'prev_asn'], ['description', 'prev_description']

Stage 23: eval

| eval suspect=if(!user_work_country==session_country,"Sketchy","Normal")
suspect =
1."Sketchy"
-"Normal"(default)

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"(default)

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.

FieldKindExcluded values
Authentication.usereq"unknown"
prev_cityeq(no value, null check)
prev_countryeq(no value, null check)
prev_cityeq"-"
session_cityeq"-"
1eq1

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.

FieldKindValues
Authentication.actioneq
  • "success"
Authentication.appin
  • "*GlobalProtect"
  • "Atlassian Cloud"
  • "Jira*"
  • "Slack"
  • "Workday"
  • "Zoom"
distancegt
  • 750
indexeq
  • "firewall"
prev_cityis_not_null
  • (no value, null check)
problemeq
  • "Yes"
session_cityis_not_null
  • (no value, null check)
session_latis_not_null
  • (no value, null check)
session_lonis_not_null
  • (no value, null check)
sourcetypeeq
  • "pan:globalprotect"
speedgt
  • 500

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.

StageTerm
9iplocation
9src