LoFP LoFP / one shared subscription key in front of users in different countries. split that key per application, or raise the distance and speed thresholds in the query.

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.*