Detection rules › Panther

Panther rules: impossible

Impossible Travel for Login Action

#
Severity
high
Log types
Asana.Audit, AWS.CloudTrail, Notion.AuditLogs, Okta.SystemLog
Tags
Identity & Access Management, Initial Access:Valid Accounts
Reference
expertinsights.com
Source
github.com/panther-labs/panther-analysis

A user has subsequent logins from two geographic locations that are very far apart

MITRE ATT&CK coverage

TacticTechniques
Initial Access

Detection logic

from datetime import datetime, timedelta
from json import dumps, loads

import panther_event_type_helpers as event_type
from panther_base_helpers import deep_get, resolve_timestamp_string
from panther_detection_helpers.caching import get_string_set, put_string_set
from panther_ipinfo_helpers import km_between_ipinfo_loc
from panther_lookuptable_helpers import LookupTableMatches

# pylint: disable=global-variable-undefined

SATELLITE_NETWORK_ASNS = ["AS22351"]


def gen_key(event):
    """
    gen_key uses the data_model for the logtype to cache
    an entry that is specific to the Log Source ID

    The data_model needs to answer to "actor_user"
    """
    rule_name = event.get("p_source_label")
    actor = event.udm("actor_user")
    if None in [rule_name, actor]:
        return None
    return f"{rule_name.replace(' ', '')}..{actor}"


# a user-defined function that checks for client's whitelisted IP addresses
def is_ip_whitelisted(event):  # pylint: disable=unused-argument
    return False


def rule(event):
    # too-many-return-statements due to error checking
    # pylint: disable=global-statement,too-many-return-statements,too-complex,too-many-statements
    # pylint: disable=too-many-branches
    global EVENT_CITY_TRACKING
    global CACHE_KEY
    global IS_VPN
    global IS_PRIVATE_RELAY
    global IS_SATELLITE_NETWORK

    EVENT_CITY_TRACKING = {}
    CACHE_KEY = ""
    IS_VPN = False
    IS_PRIVATE_RELAY = False
    IS_SATELLITE_NETWORK = False

    # check if the IP address is in the client's whitelisted IP addresses
    if is_ip_whitelisted(event):
        return False

    # Only evaluate successful logins
    if event.udm("event_type") != event_type.SUCCESSFUL_LOGIN:
        return False

    p_event_datetime = resolve_timestamp_string(event.get("p_event_time"))
    if p_event_datetime is None:
        # we couldn't go from p_event_time to a datetime object
        # we need to do this in order to make later time comparisons generic
        return False

    new_login_stats = {
        "p_event_time": p_event_datetime.isoformat(),
        "source_ip": event.udm("source_ip"),
    }
    #
    src_ip_enrichments = LookupTableMatches().p_matches(event, event.udm("source_ip"))

    # stuff everything from ipinfo_location into the new_login_stats
    # new_login_stats is the value that we will cache for this key
    ipinfo_location = deep_get(src_ip_enrichments, "ipinfo_location")
    if ipinfo_location is None:
        return False
    new_login_stats.update(ipinfo_location)

    # Bail out if we have a None value in set as it causes false positives
    if None in new_login_stats.values():
        return False

    ## Check for VPN or Private Relay
    ipinfo_privacy = deep_get(src_ip_enrichments, "ipinfo_privacy")
    if ipinfo_privacy is not None:
        ###  Do VPN/private relay
        IS_PRIVATE_RELAY = all(
            [
                deep_get(ipinfo_privacy, "relay", default=False),
                deep_get(ipinfo_privacy, "service", default="") == "Apple Private Relay",
            ]
        )
        # We've found that some places, like WeWork locations,
        #   have the VPN attribute set to true, but do not have a
        #   service name entry.
        # We have noticed VPN connections with commercial VPN
        #   offerings have the VPN attribute set to true, and
        #   do have a service name entry
        IS_VPN = all(
            [
                deep_get(ipinfo_privacy, "vpn", default=False),
                deep_get(ipinfo_privacy, "service", default="") != "",
            ]
        )
    # Some satellite networks used during plane travel don't always
    #   register properly as VPN's, so we have a separate check here.
    IS_SATELLITE_NETWORK = (
        deep_get(src_ip_enrichments, "ipinfo_asn", "asn", default="") in SATELLITE_NETWORK_ASNS
    )

    if any((IS_VPN, IS_PRIVATE_RELAY, IS_SATELLITE_NETWORK)):
        new_login_stats.update(
            {
                "is_vpn": f"{IS_VPN}",
                "is_apple_priv_relay": f"{IS_PRIVATE_RELAY}",
                "is_satellite_network": f"{IS_SATELLITE_NETWORK}",
                "service_name": f"{deep_get(ipinfo_privacy, 'service', default='<NO_SERVICE>')}",
                "NOTE": "APPLE PRIVATE RELAY AND VPN LOGINS ARE NOT CACHED FOR COMPARISON",
            }
        )

    # Generate a unique cache key for each user per log type
    CACHE_KEY = gen_key(event)
    if not CACHE_KEY:
        # We can't save without a cache key
        return False
    # Retrieve the prior login info from the cache, if any
    last_login = get_string_set(CACHE_KEY)
    # If we haven't seen this user login in the past 1 day,
    # store this login for future use and don't alert
    if not last_login:
        if not any((IS_VPN, IS_PRIVATE_RELAY, IS_SATELLITE_NETWORK)):
            put_string_set(
                key=CACHE_KEY,
                val=[dumps(new_login_stats)],
                epoch_seconds=int((datetime.utcnow() + timedelta(days=1)).timestamp()),
            )
        return False
    # Load the last login from the cache into an object we can compare
    # str check is in place for unit test mocking
    if isinstance(last_login, str):
        tmp_last_login = loads(last_login)
        last_login = []
        for l_l in tmp_last_login:
            last_login.append(dumps(l_l))
    last_login_stats = loads(last_login.pop())

    distance = km_between_ipinfo_loc(last_login_stats, new_login_stats)
    old_time = resolve_timestamp_string(deep_get(last_login_stats, "p_event_time"))
    new_time = resolve_timestamp_string(deep_get(new_login_stats, "p_event_time"))
    time_delta = (new_time - old_time).total_seconds() / 3600  # seconds in an hour

    # Don't let time_delta be 0 (divide by zero error below)
    time_delta = time_delta or 0.0001
    # Calculate speed in Kilometers / Hour
    speed = distance / time_delta

    # Calculation is complete, write the current login to the cache
    # Only if non-VPN non-relay!
    if not any((IS_VPN, IS_PRIVATE_RELAY, IS_SATELLITE_NETWORK)):
        put_string_set(
            key=CACHE_KEY,
            val=[dumps(new_login_stats)],
            epoch_seconds=int((datetime.utcnow() + timedelta(days=1)).timestamp()),
        )

    EVENT_CITY_TRACKING["previous"] = last_login_stats
    EVENT_CITY_TRACKING["current"] = new_login_stats
    EVENT_CITY_TRACKING["speed"] = int(speed)
    EVENT_CITY_TRACKING["speed_units"] = "km/h"
    EVENT_CITY_TRACKING["distance"] = int(distance)
    EVENT_CITY_TRACKING["distance_units"] = "km"
    if deep_get(EVENT_CITY_TRACKING, "previous", "source_ip", default="<NO_PREV_IP>") == deep_get(
        EVENT_CITY_TRACKING, "current", "source_ip", default="<NO_NEW_IP>"
    ):
        # Same IP address, no alert
        return False
    return speed > 900  # Boeing 747 cruising speed


def title(event):
    #
    log_source = event.get("p_source_label", "<NO_SOURCE_LABEL>")
    old_city = deep_get(EVENT_CITY_TRACKING, "previous", "city", default="<NO_PREV_CITY>")
    new_city = deep_get(EVENT_CITY_TRACKING, "current", "city", default="<NO_PREV_CITY>")
    speed = deep_get(EVENT_CITY_TRACKING, "speed", default="<NO_SPEED>")
    distance = deep_get(EVENT_CITY_TRACKING, "distance", default="<NO_DISTANCE>")
    old_ip = deep_get(EVENT_CITY_TRACKING, "previous", "source_ip", default="<NO_PREV_IP>")
    new_ip = deep_get(EVENT_CITY_TRACKING, "current", "source_ip", default="<NO_NEW_IP>")
    return (
        f"Impossible Travel: [{event.udm('actor_user')}] "
        f"in [{log_source}] went [{speed}] km/h for [{distance}] km "
        f"between [{old_city}/{old_ip}] and [{new_city}/{new_ip}]"
    )


def dedup(event):  # pylint: disable=W0613
    return CACHE_KEY


def alert_context(event):
    context = {
        "actor_user": event.udm("actor_user"),
    }
    context.update(EVENT_CITY_TRACKING)
    return context


def severity(_):
    if any((IS_VPN, IS_PRIVATE_RELAY, IS_SATELLITE_NETWORK)):
        return "INFO"
    # time = distance/speed
    distance = deep_get(EVENT_CITY_TRACKING, "distance", default=None)
    speed = deep_get(EVENT_CITY_TRACKING, "speed", default=None)
    if speed and distance:
        time = distance / speed
        # time of 0.1666 is 10 minutes
        if time < 0.1666 and distance < 50:
            # This is likely a GEOIP inaccuracy
            return "LOW"
    return "HIGH"

Rule specification

AnalysisType: rule
Filename: impossible_travel_login.py
RuleID: "Standard.ImpossibleTravel.Login"
DisplayName: "Impossible Travel for Login Action"
Enabled: true
LogTypes:
  - Asana.Audit
  - AWS.CloudTrail
  - Notion.AuditLogs
  - Okta.SystemLog
Tags:
  - Identity & Access Management
  - Initial Access:Valid Accounts
Reports:
  MITRE ATT&CK:
    - TA0001:T1078
Severity: High
Description: A user has subsequent logins from two geographic locations that are very far apart
Runbook: |
  Reach out to the user if needed to validate the activity, then lock the account.

  If the user responds that the geolocation on the new location is incorrect, you can directly
  report the inaccuracy via  https://ipinfo.io/corrections
Reference: https://expertinsights.com/insights/what-are-impossible-travel-logins/#:~:text=An%20impossible%20travel%20login%20is,of%20the%20logins%20is%20fraudulent
SummaryAttributes:
  - p_any_usernames
  - p_any_ip_addresses
  - p_any_domain_names
# All test cases for this detection will need to include:
# * p_log_type ( for udm)
# Alerting test cases for this detection will need to include:
# * p_source_label
# * p_event_time

Stages and Predicates

Fires on Asana.Audit, AWS.CloudTrail, Notion.AuditLogs (and 1 more) events when the condition below holds.

Condition

  • event_type is successful_login

This rule also runs imperative logic the parser cannot express as a filter. The conditions above are the structured part it could extract.

Indicators

These rows show field, operator, and value matches.

Output fields

Fields the rule emits when it matches, drawn from the rule's alert_context.

Field
actor_user
p_source_label

Response runbook

Reach out to the user if needed to validate the activity, then lock the account.

If the user responds that the geolocation on the new location is incorrect, you can directly

report the inaccuracy via https://ipinfo.io/corrections

Worked example

A sample event from the rule's unit tests that triggers a match.

Sample Test Event
{
  "additionalEventData": {
    "MFAUsed": "No",
    "MobileVersion": "No"
  },
  "awsRegion": "us-east-1",
  "eventCategory": "Management",
  "eventName": "ConsoleLogin",
  "eventSource": "signin.amazonaws.com",
  "eventTime": "2023-05-26 20:14:51",
  "eventType": "AwsConsoleSignIn",
  "eventVersion": "1.08",
  "managementEvent": true,
  "p_enrichment": {
    "ipinfo_location": {
      "sourceIPAddress": {
        "city": "Auckland",
        "country": "NZ",
        "lat": "-36.84853",
        "lng": "174.76349",
        "p_match": "12.12.12.12",
        "postal_code": "1010",
        "region": "Auckland",
        "region_code": "AUK",
        "timezone": "Pacific/Auckland"
      }
    }
  },
  "p_event_time": "2023-05-26 20:14:51",
  "p_log_type": "AWS.CloudTrail",
  "p_parse_time": "2023-05-26 20:19:14.002",
  "p_source_label": "LogSource Name",
  "readOnly": false,
  "recipientAccountId": "123456789012",
  "responseElements": {
    "ConsoleLogin": "Success"
  },
  "sourceIPAddress": "12.12.12.12",
  "tlsDetails": {
    "cipherSuite": "TLS_AES_128_GCM_SHA256",
    "clientProvidedHostHeader": "signin.aws.amazon.com",
    "tlsVersion": "TLSv1.3"
  },
  "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36",
  "userIdentity": {
    "accountId": "123456789012",
    "arn": "arn:aws:iam::123456789012:user/tester",
    "principalId": "1111",
    "type": "IAMUser",
    "userName": "tester"
  }
}