LoFP LoFP / vpn, proxy, or cloud egress that geolocates incorrectly or jumps between distant points of presence. validate the ip locations and asns before rotating credentials.

Techniques

Sample rules

AWS Bedrock Model Invocation Impossible Travel

Description

Detects the same AWS principal invoking Amazon Bedrock models (InvokeModel, Converse, and their streaming variants) from geographically distant source IPs within a short window at an impossible travel speed. Stolen access keys and session credentials are frequently resold or reused for LLMjacking, and an inference call from a second continent minutes after a legitimate one is a strong indicator that the credential is being used by someone else.

Detection logic

from logs-aws.cloudtrail-*
| where
    data_stream.dataset == "aws.cloudtrail" and
    event.provider == "bedrock.amazonaws.com" and
    event.action in ("InvokeModel", "InvokeModelWithResponseStream", "Converse", "ConverseStream") and
    event.outcome == "success" and
    aws.cloudtrail.user_identity.arn is not null and
    source.ip is not null and
    source.geo.location is not null and
    source.geo.country_name is not null
| eval
    Esql.identity = coalesce(
        aws.cloudtrail.user_identity.session_context.session_issuer.arn,
        aws.cloudtrail.user_identity.arn
    ),
    Esql.source_geo_lat = st_y(source.geo.location),
    Esql.source_geo_lon = st_x(source.geo.location)
| where Esql.source_geo_lat is not null and Esql.source_geo_lon is not null
| stats
    Esql.first_lat = first(Esql.source_geo_lat, @timestamp),
    Esql.first_lon = first(Esql.source_geo_lon, @timestamp),
    Esql.last_lat = last(Esql.source_geo_lat, @timestamp),
    Esql.last_lon = last(Esql.source_geo_lon, @timestamp),
    Esql.event_count = count(*),
    Esql.country_count = count_distinct(source.geo.country_name),
    Esql.source_ip_values = values(source.ip),
    Esql.source_geo_country_name_values = values(source.geo.country_name),
    Esql.source_geo_city_name_values = values(source.geo.city_name),
    Esql.source_as_organization_name_values = values(source.as.organization.name),
    Esql.event_action_values = values(event.action),
    Esql.user_agent_original_values = values(user_agent.original),
    Esql.aws_cloudtrail_user_identity_arn_values = values(aws.cloudtrail.user_identity.arn),
    Esql.aws_cloudtrail_user_identity_type_values = values(aws.cloudtrail.user_identity.type),
    Esql.aws_cloudtrail_user_identity_access_key_id_values = values(aws.cloudtrail.user_identity.access_key_id),
    Esql.cloud_region_values = values(cloud.region),
    Esql.timestamp_first_seen = min(@timestamp),
    Esql.timestamp_last_seen = max(@timestamp)
  by
    Esql.identity,
    cloud.account.id
| where Esql.event_count >= 2 and Esql.country_count >= 2
| eval
    Esql.p1 = to_geopoint(concat("POINT(", to_string(Esql.first_lon), " ", to_string(Esql.first_lat), ")")),
    Esql.p2 = to_geopoint(concat("POINT(", to_string(Esql.last_lon), " ", to_string(Esql.last_lat), ")"))
| eval
    Esql.distance_km = round(st_distance(Esql.p1, Esql.p2) / 1000.0, 0),
    Esql.window_seconds = date_diff("second", Esql.timestamp_first_seen, Esql.timestamp_last_seen),
    Esql.travel_kmh = case(Esql.window_seconds > 0, round(Esql.distance_km * 3600.0 / Esql.window_seconds, 0), null),
    source.ip = mv_first(Esql.source_ip_values)
| where Esql.distance_km >= 500 and Esql.travel_kmh >= 800
| keep
    Esql.identity,
    cloud.account.id,
    source.ip,
    Esql.source_ip_values,
    Esql.source_geo_country_name_values,
    Esql.source_geo_city_name_values,
    Esql.source_as_organization_name_values,
    Esql.event_action_values,
    Esql.user_agent_original_values,
    Esql.aws_cloudtrail_user_identity_arn_values,
    Esql.aws_cloudtrail_user_identity_type_values,
    Esql.aws_cloudtrail_user_identity_access_key_id_values,
    Esql.cloud_region_values,
    Esql.distance_km,
    Esql.travel_kmh,
    Esql.window_seconds,
    Esql.country_count,
    Esql.event_count,
    Esql.timestamp_first_seen,
    Esql.timestamp_last_seen