Detection rules › Kusto

Azure Key Vault access TimeSeries anomaly

Status
available
Severity
low
Time window
14d
Group by
CallerIPAddress, LatestAnomalyTime, Resource, clientInfo_s, id_s
Source
github.com/Azure/Azure-Sentinel

'Identifies a sudden increase in count of Azure Key Vault secret or vault access operations by CallerIPAddress. The query leverages a built-in KQL anomaly detection algorithm to find large deviations from baseline Azure Key Vault access patterns. Any sudden increase in the count of Azure Key Vault accesses can be an indication of adversary dumping credentials via automated methods. If you are seeing any noise, try filtering known source(IP/Account) and user-agent combinations. TimeSeries Reference Blog: https://techcommunity.microsoft.com/t5/azure-sentinel/looking-for-unknown-anomalies-what-is-normal-time-series/ba-p/555052'

MITRE ATT&CK coverage

TacticTechniques
Credential AccessT1003 OS Credential Dumping

Rules detecting the same action

Other rules on this platform that filter on the same API call or operation.

Rule body kusto

id: 0914adab-90b5-47a3-a79f-7cdcac843aa7
name: Azure Key Vault access TimeSeries anomaly
description: |
  'Identifies a sudden increase in count of Azure Key Vault secret or vault access operations by CallerIPAddress. The query leverages a built-in KQL anomaly detection algorithm to find large deviations from baseline Azure Key Vault access patterns.
  Any sudden increase in the count of Azure Key Vault accesses can be an indication of adversary dumping credentials via automated methods. If you are seeing any noise, try filtering known source(IP/Account) and user-agent combinations.
  TimeSeries Reference Blog: https://techcommunity.microsoft.com/t5/azure-sentinel/looking-for-unknown-anomalies-what-is-normal-time-series/ba-p/555052'
severity: Low
status: Available
requiredDataConnectors:
  - connectorId: AzureKeyVault
    dataTypes:
      - KeyVaultData
queryFrequency: 1d
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - CredentialAccess
relevantTechniques:
  - T1003
query: |
  let starttime = 14d;
  let timeframe = 1d;
  let scorethreshold = 3;
  let baselinethreshold = 25;
  // To avoid any False Positives, filtering using AppId is recommended. For example the AppId 509e4652-da8d-478d-a730-e9d4a1996ca4 has been added in the query as it corresponds
  // to Azure Resource Graph performing VaultGet operations for indexing and syncing all tracked resources across Azure.
  let Allowedappid = dynamic(["509e4652-da8d-478d-a730-e9d4a1996ca4"]);
  let OperationList = dynamic(
  ["SecretGet", "KeyGet", "VaultGet"]);
  let TimeSeriesData = AzureDiagnostics
  | where TimeGenerated between (startofday(ago(starttime))..startofday(now()))
  | where not((identity_claim_appid_g in (Allowedappid)) and OperationName == 'VaultGet')
    | where ResourceType =~ "VAULTS" and ResultType =~ "Success"
  | where OperationName in (OperationList)
  | extend ResultType = column_ifexists("ResultType", "None"), CallerIPAddress = column_ifexists("CallerIPAddress", "None")
  | where ResultType !~ "None" and isnotempty(ResultType)
  | where CallerIPAddress !~ "None" and isnotempty(CallerIPAddress)
  | project TimeGenerated, OperationName, Resource, CallerIPAddress
  | make-series HourlyCount=count() on TimeGenerated from startofday(ago(starttime)) to startofday(now()) step timeframe by CallerIPAddress;
  //Filter anomolies against TimeSeriesData
  let TimeSeriesAlerts = TimeSeriesData
  | extend (anomalies, score, baseline) = series_decompose_anomalies(HourlyCount, scorethreshold, -1, 'linefit')
  | mv-expand HourlyCount to typeof(double), TimeGenerated to typeof(datetime), anomalies to typeof(double),score to typeof(double), baseline to typeof(long)
  | where anomalies > 0 | extend AnomalyHour = TimeGenerated
  | where baseline > baselinethreshold // Filtering low count events per baselinethreshold
  | project CallerIPAddress, AnomalyHour, TimeGenerated, HourlyCount, baseline, anomalies, score;
  let AnomalyHours = TimeSeriesAlerts | where TimeGenerated > ago(2d) | project TimeGenerated;
  // Filter the alerts since specified timeframe
  TimeSeriesAlerts
  | where TimeGenerated > ago(2d)
  // Join against base logs since specified timeframe to retrive records associated with the hour of anomoly
  | join kind = innerunique (
  AzureDiagnostics
  | where TimeGenerated > ago(2d)
  | where not((identity_claim_appid_g in (Allowedappid)) and OperationName == 'VaultGet')
  | where ResourceType =~ "VAULTS" and ResultType =~ "Success"
  | where OperationName in (OperationList)
  | extend DateHour = bin(TimeGenerated, 1h) // create a new column and round to hour
  | where DateHour in ((AnomalyHours)) //filter the dataset to only selected anomaly hours
  | extend ResultType = column_ifexists("ResultType", "NoResultType")
  | extend requestUri_s = column_ifexists("requestUri_s", "None"), identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g = column_ifexists("identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g", "None"),identity_claim_oid_g = column_ifexists("identity_claim_oid_g", ""),
    identity_claim_upn_s = column_ifexists("identity_claim_upn_s", "")
  | extend
      CallerObjectId = iff(isempty(identity_claim_oid_g), identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g, identity_claim_oid_g),
      CallerObjectUPN = iff(isempty(identity_claim_upn_s), identity_claim_http_schemas_xmlsoap_org_ws_2005_05_identity_claims_upn_s, identity_claim_upn_s)
  | extend id_s = column_ifexists("id_s", "None"), CallerIPAddress = column_ifexists("CallerIPAddress", "None"), clientInfo_s = column_ifexists("clientInfo_s", "None")
  | summarize PerOperationCount=count(), LatestAnomalyTime = arg_max(TimeGenerated,*) by bin(TimeGenerated,1h), Resource, OperationName, id_s, CallerIPAddress, identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g, identity_claim_oid_g, requestUri_s, clientInfo_s
  ) on CallerIPAddress
  | extend
      CallerObjectId = iff(isempty(identity_claim_oid_g), identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g, identity_claim_oid_g),
      CallerObjectUPN = iff(isempty(identity_claim_upn_s), identity_claim_http_schemas_xmlsoap_org_ws_2005_05_identity_claims_upn_s, identity_claim_upn_s)
  | summarize EventCount=count(), OperationNameList = make_set(OperationName,1000), RequestURLList = make_set(requestUri_s, 100), AccountList = make_set(CallerObjectId, 100), AccountMax = arg_max(CallerObjectId,*) by Resource, id_s, clientInfo_s, LatestAnomalyTime
  | extend timestamp = LatestAnomalyTime
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: Name
        columnName: AccountMax
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: CallerIPAddress
version: 1.0.6
kind: Scheduled

Stages and Predicates

Parameters

let starttime = 14d;
let timeframe = 1d;
let scorethreshold = 3;
let baselinethreshold = 25;
let Allowedappid = dynamic(["509e4652-da8d-478d-a730-e9d4a1996ca4"]);

Let binding: OperationList

let OperationList = dynamic(
["SecretGet", "KeyGet", "VaultGet"]);

Let binding: AnomalyHours

let AnomalyHours = TimeSeriesAlerts | where TimeGenerated > ago(2d) | project TimeGenerated;

Derived from TimeSeriesAlerts.

The stages below define let TimeSeriesAlerts (the rule's main pipeline source).

Stage 1: source

AzureDiagnostics

Stage 2: where

| where TimeGenerated between (startofday(ago(starttime))..startofday(now()))

Stage 3: where

| where not((identity_claim_appid_g in (Allowedappid)) and OperationName == 'VaultGet')

Stage 4: where

| where ResourceType =~ "VAULTS" and ResultType =~ "Success"

Stage 5: where

| where OperationName in (OperationList)

References OperationList (defined above).

Stage 6: extend

| extend ResultType = column_ifexists("ResultType", "None"), CallerIPAddress = column_ifexists("CallerIPAddress", "None")

Stage 7: where

| where ResultType !~ "None" and isnotempty(ResultType)

Stage 8: where

| where CallerIPAddress !~ "None" and isnotempty(CallerIPAddress)

Stage 9: project

| project TimeGenerated, OperationName, Resource, CallerIPAddress

The stages below score time-series anomalies (make-series, series_decompose_anomalies).

Stage 10: summarize

| make-series HourlyCount=count() on TimeGenerated from startofday(ago(starttime)) to startofday(now()) step timeframe by CallerIPAddress

Stage 11: extend

| extend (anomalies, score, baseline) = series_decompose_anomalies(HourlyCount, scorethreshold, -1, 'linefit')

Stage 12: mv-expand

| mv-expand HourlyCount to typeof(double), TimeGenerated to typeof(datetime), anomalies to typeof(double),score to typeof(double), baseline to typeof(long)

Stage 13: where

| where anomalies > 0

Stage 14: extend

| extend AnomalyHour = TimeGenerated

Stage 15: where

| where baseline > baselinethreshold

Stage 16: project

| project CallerIPAddress, AnomalyHour, TimeGenerated, HourlyCount, baseline, anomalies, score

The stages below run on TimeSeriesAlerts (the outer pipeline).

Stage 17: where

TimeSeriesAlerts
| where TimeGenerated > ago(2d)

Stage 18: join

| join kind = innerunique (
AzureDiagnostics
| where TimeGenerated > ago(2d)
| where not((identity_claim_appid_g in (Allowedappid)) and OperationName == 'VaultGet')
| where ResourceType =~ "VAULTS" and ResultType =~ "Success"
| where OperationName in (OperationList)
| extend DateHour = bin(TimeGenerated, 1h)
| where DateHour in ((AnomalyHours))
| extend ResultType = column_ifexists("ResultType", "NoResultType")
| extend requestUri_s = column_ifexists("requestUri_s", "None"), identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g = column_ifexists("identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g", "None"),identity_claim_oid_g = column_ifexists("identity_claim_oid_g", ""),
  identity_claim_upn_s = column_ifexists("identity_claim_upn_s", "")
| extend
    CallerObjectId = iff(isempty(identity_claim_oid_g), identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g, identity_claim_oid_g),
    CallerObjectUPN = iff(isempty(identity_claim_upn_s), identity_claim_http_schemas_xmlsoap_org_ws_2005_05_identity_claims_upn_s, identity_claim_upn_s)
| extend id_s = column_ifexists("id_s", "None"), CallerIPAddress = column_ifexists("CallerIPAddress", "None"), clientInfo_s = column_ifexists("clientInfo_s", "None")
| summarize PerOperationCount=count(), LatestAnomalyTime = arg_max(TimeGenerated,*) by bin(TimeGenerated,1h), Resource, OperationName, id_s, CallerIPAddress, identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g, identity_claim_oid_g, requestUri_s, clientInfo_s
) on CallerIPAddress

Stage 19: extend

| extend
    CallerObjectId = iff(isempty(identity_claim_oid_g), identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g, identity_claim_oid_g),
    CallerObjectUPN = iff(isempty(identity_claim_upn_s), identity_claim_http_schemas_xmlsoap_org_ws_2005_05_identity_claims_upn_s, identity_claim_upn_s)
CallerObjectId =
ifisempty(identity_claim_oid_g)identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g
elseidentity_claim_oid_g
CallerObjectUPN =
ifisempty(identity_claim_upn_s)identity_claim_http_schemas_xmlsoap_org_ws_2005_05_identity_claims_upn_s
elseidentity_claim_upn_s

Stage 20: summarize

| summarize EventCount=count(), OperationNameList = make_set(OperationName,1000), RequestURLList = make_set(requestUri_s, 100), AccountList = make_set(CallerObjectId, 100), AccountMax = arg_max(CallerObjectId,*) by Resource, id_s, clientInfo_s, LatestAnomalyTime

Stage 21: extend

| extend timestamp = LatestAnomalyTime

Exclusions

Top-level NOT(...) conjuncts: predicates this rule actively suppresses.

FieldKindExcluded values
OperationNameeqVaultGet
identity_claim_appid_geqAllowedappid
OperationNameeqVaultGet
identity_claim_appid_geqAllowedappid

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
CallerIPAddressis_not_null
  • (no value, null check)
CallerIPAddressne
  • None
OperationNamein
  • KeyGet transforms: cased
  • SecretGet transforms: cased
  • VaultGet transforms: cased
ResourceTypeeq
  • VAULTS
ResultTypeeq
  • Success
ResultTypeis_not_null
  • (no value, null check)
ResultTypene
  • None
anomaliesgt
  • 0 transforms: cased
baselinegt
  • 25 transforms: cased

Output fields

Fields the rule emits when it matches. Chronicle authors list these in the outcome block; they appear on the detection and $risk_score drives alerting. Sentinel / Defender XDR rules build them up through project / summarize / extend stages. Sentinel maps these into alert fields via entityMappings and customDetails; Defender XDR custom detections surface them as alert fields directly.

FieldSource
AccountListsummarize
AccountMaxsummarize
EventCountsummarize
LatestAnomalyTimesummarize
OperationNameListsummarize
RequestURLListsummarize
Resourcesummarize
clientInfo_ssummarize
id_ssummarize
timestampextend