LoFP LoFP / a subscription key used from a vpn, a cloud vm, or a corporate proxy whose egress geo is far from the developer's network. a home isp and a cloud build agent on the same key look like impossible travel.

Techniques

Sample rules

Microsoft Foundry Subscription Key Impossible Travel

Description

Detects the same API Management subscription key calling Microsoft Foundry from source IPs in two or more countries, far enough apart and fast enough that the hop is not physical travel. Calls are grouped by apim_subscription_id. Requests with no subscription key are ignored, because those are unauthenticated calls and share no key.

Detection logic

from logs-azure_ai_foundry.logs-*
| where
    data_stream.dataset == "azure_ai_foundry.logs" and
    azure.ai_foundry.category == "GatewayLogs" and
    azure.ai_foundry.properties.apim_subscription_id 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.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_region_name_values = values(source.geo.region_name),
    Esql.source_geo_city_name_values = values(source.geo.city_name),
    Esql.source_as_organization_name_values = values(source.as.organization.name),
    Esql.azure_ai_foundry_properties_user_agent_values = values(azure.ai_foundry.properties.user_agent),
    Esql.azure_ai_foundry_properties_backend_response_code_values = values(azure.ai_foundry.properties.backend_response_code),
    Esql.azure_ai_foundry_properties_apim_subscription_id = min(azure.ai_foundry.properties.apim_subscription_id),
    Esql.timestamp_first_seen = min(@timestamp),
    Esql.timestamp_last_seen = max(@timestamp)
  by
    azure.ai_foundry.properties.apim_subscription_id,
    azure.ai_foundry.properties.api_id,
    azure.ai_foundry.properties.operation_id,
    azure.resource.name,
    azure.resource.group,
    url.domain,
    url.path
| 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_minutes = date_diff("minute", Esql.timestamp_first_seen, Esql.timestamp_last_seen),
    Esql.travel_kmh = case(Esql.window_minutes > 0, round(Esql.distance_km * 60.0 / Esql.window_minutes, 0), null),
    source.ip = MV_FIRST(Esql.source_ip_values)
| where Esql.distance_km >= 500 and Esql.travel_kmh >= 800
| keep
    azure.ai_foundry.properties.apim_subscription_id,
    azure.ai_foundry.properties.api_id,
    azure.ai_foundry.properties.operation_id,
    azure.resource.name,
    azure.resource.group,
    url.domain,
    url.path,
    source.ip,
    Esql.*