Azure Key Vault access TimeSeries anomaly
Id | 0914adab-90b5-47a3-a79f-7cdcac843aa7 |
Rulename | Azure Key Vault access TimeSeries anomaly |
Description | Indentifies 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 |
Tactics | CredentialAccess |
Techniques | T1003 |
Required data connectors | AzureKeyVault |
Kind | Scheduled |
Query frequency | 1d |
Query period | 14d |
Trigger threshold | 0 |
Trigger operator | gt |
Source Uri | https://github.com/Azure/Azure-Sentinel/blob/master/Solutions/Azure Key Vault/Analytic Rules/TimeSeriesKeyvaultAccessAnomaly.yaml |
Version | 1.0.3 |
Arm template | 0914adab-90b5-47a3-a79f-7cdcac843aa7.json |
let starttime = 14d;
let timeframe = 1d;
let scorethreshold = 3;
let baselinethreshold = 5;
// 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')
| extend ResultType = columnifexists("ResultType", "None"), CallerIPAddress = columnifexists("CallerIPAddress", "None")
| where ResultType !~ "None" and isnotempty(ResultType)
| where CallerIPAddress !~ "None" and isnotempty(CallerIPAddress)
| where ResourceType =~ "VAULTS" and ResultType =~ "Success"
| where OperationName in (OperationList)
| project TimeGenerated, OperationName, Resource, CallerIPAddress
| make-series HourlyCount=count() on TimeGenerated from startofday(ago(starttime)) to startofday(now()) step timeframe by Resource;
//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 Resource, 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 (
AzureDiagnostics
| where TimeGenerated > ago(timeframe)
| where not((identity_claim_appid_g in (Allowedappid)) and OperationName == 'VaultGet')
| 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 = columnifexists("ResultType", "NoResultType")
| extend requestUri_s = columnifexists("requestUri_s", "None"), identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g = columnifexists("identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g", "None")
| extend id_s = columnifexists("id_s", "None"), CallerIPAddress = columnifexists("CallerIPAddress", "None"), clientInfo_s = columnifexists("clientInfo_s", "None")
| where ResultType !~ "None" and isnotempty(ResultType)
| where identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g !~ "None" and isnotempty(identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g)
| where id_s !~ "None" and isnotempty(id_s)
| where CallerIPAddress !~ "None" and isnotempty(CallerIPAddress)
| where clientInfo_s !~ "None" and isnotempty(clientInfo_s)
| where requestUri_s !~ "None" and isnotempty(requestUri_s)
| where ResourceType =~ "VAULTS" and ResultType =~ "Success"
| where OperationName in (OperationList)
| 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, requestUri_s, clientInfo_s
) on Resource, TimeGenerated
| summarize EventCount=count(), OperationNameList = make_set(OperationName), RequestURLList = make_set(requestUri_s, 100), AccountList = make_set(identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g, 100), AccountMax = arg_max(identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g,*) by Resource, id_s, clientInfo_s, LatestAnomalyTime
| extend timestamp = LatestAnomalyTime, IPCustomEntity = CallerIPAddress, AccountCustomEntity = AccountMax
queryFrequency: 1d
entityMappings:
- entityType: Account
fieldMappings:
- columnName: AccountCustomEntity
identifier: FullName
- entityType: IP
fieldMappings:
- columnName: IPCustomEntity
identifier: Address
severity: Low
triggerThreshold: 0
relevantTechniques:
- T1003
query: |
let starttime = 14d;
let timeframe = 1d;
let scorethreshold = 3;
let baselinethreshold = 5;
// 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')
| extend ResultType = columnifexists("ResultType", "None"), CallerIPAddress = columnifexists("CallerIPAddress", "None")
| where ResultType !~ "None" and isnotempty(ResultType)
| where CallerIPAddress !~ "None" and isnotempty(CallerIPAddress)
| where ResourceType =~ "VAULTS" and ResultType =~ "Success"
| where OperationName in (OperationList)
| project TimeGenerated, OperationName, Resource, CallerIPAddress
| make-series HourlyCount=count() on TimeGenerated from startofday(ago(starttime)) to startofday(now()) step timeframe by Resource;
//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 Resource, 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 (
AzureDiagnostics
| where TimeGenerated > ago(timeframe)
| where not((identity_claim_appid_g in (Allowedappid)) and OperationName == 'VaultGet')
| 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 = columnifexists("ResultType", "NoResultType")
| extend requestUri_s = columnifexists("requestUri_s", "None"), identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g = columnifexists("identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g", "None")
| extend id_s = columnifexists("id_s", "None"), CallerIPAddress = columnifexists("CallerIPAddress", "None"), clientInfo_s = columnifexists("clientInfo_s", "None")
| where ResultType !~ "None" and isnotempty(ResultType)
| where identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g !~ "None" and isnotempty(identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g)
| where id_s !~ "None" and isnotempty(id_s)
| where CallerIPAddress !~ "None" and isnotempty(CallerIPAddress)
| where clientInfo_s !~ "None" and isnotempty(clientInfo_s)
| where requestUri_s !~ "None" and isnotempty(requestUri_s)
| where ResourceType =~ "VAULTS" and ResultType =~ "Success"
| where OperationName in (OperationList)
| 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, requestUri_s, clientInfo_s
) on Resource, TimeGenerated
| summarize EventCount=count(), OperationNameList = make_set(OperationName), RequestURLList = make_set(requestUri_s, 100), AccountList = make_set(identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g, 100), AccountMax = arg_max(identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g,*) by Resource, id_s, clientInfo_s, LatestAnomalyTime
| extend timestamp = LatestAnomalyTime, IPCustomEntity = CallerIPAddress, AccountCustomEntity = AccountMax
id: 0914adab-90b5-47a3-a79f-7cdcac843aa7
triggerOperator: gt
version: 1.0.3
requiredDataConnectors:
- connectorId: AzureKeyVault
dataTypes:
- KeyVaultData
description: |
'Indentifies 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'
queryPeriod: 14d
OriginalUri: https://github.com/Azure/Azure-Sentinel/blob/master/Solutions/Azure Key Vault/Analytic Rules/TimeSeriesKeyvaultAccessAnomaly.yaml
status: Available
name: Azure Key Vault access TimeSeries anomaly
tactics:
- CredentialAccess
kind: Scheduled
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"workspace": {
"type": "String"
}
},
"resources": [
{
"id": "[concat(resourceId('Microsoft.OperationalInsights/workspaces/providers', parameters('workspace'), 'Microsoft.SecurityInsights'),'/alertRules/0914adab-90b5-47a3-a79f-7cdcac843aa7')]",
"name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/0914adab-90b5-47a3-a79f-7cdcac843aa7')]",
"type": "Microsoft.OperationalInsights/workspaces/providers/alertRules",
"kind": "Scheduled",
"apiVersion": "2022-11-01",
"properties": {
"displayName": "Azure Key Vault access TimeSeries anomaly",
"description": "'Indentifies 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\nto find large deviations from baseline Azure Key Vault access patterns. Any sudden increase in the count of Azure Key Vault accesses can be an\nindication of adversary dumping credentials via automated methods. If you are seeing any noise, try filtering known source(IP/Account) and user-agent combinations.\nTimeSeries Reference Blog: https://techcommunity.microsoft.com/t5/azure-sentinel/looking-for-unknown-anomalies-what-is-normal-time-series/ba-p/555052'\n",
"severity": "Low",
"enabled": true,
"query": "let starttime = 14d;\nlet timeframe = 1d;\nlet scorethreshold = 3;\nlet baselinethreshold = 5;\n// 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\n// to Azure Resource Graph performing VaultGet operations for indexing and syncing all tracked resources across Azure.\nlet Allowedappid = dynamic([\"509e4652-da8d-478d-a730-e9d4a1996ca4\"]);\nlet OperationList = dynamic(\n[\"SecretGet\", \"KeyGet\", \"VaultGet\"]);\nlet TimeSeriesData = AzureDiagnostics\n| where TimeGenerated between (startofday(ago(starttime))..startofday(now()))\n| where not((identity_claim_appid_g in (Allowedappid)) and OperationName == 'VaultGet')\n| extend ResultType = columnifexists(\"ResultType\", \"None\"), CallerIPAddress = columnifexists(\"CallerIPAddress\", \"None\")\n| where ResultType !~ \"None\" and isnotempty(ResultType)\n| where CallerIPAddress !~ \"None\" and isnotempty(CallerIPAddress)\n| where ResourceType =~ \"VAULTS\" and ResultType =~ \"Success\"\n| where OperationName in (OperationList)\n| project TimeGenerated, OperationName, Resource, CallerIPAddress\n| make-series HourlyCount=count() on TimeGenerated from startofday(ago(starttime)) to startofday(now()) step timeframe by Resource;\n//Filter anomolies against TimeSeriesData\nlet TimeSeriesAlerts = TimeSeriesData\n| extend (anomalies, score, baseline) = series_decompose_anomalies(HourlyCount, scorethreshold, -1, 'linefit')\n| mv-expand HourlyCount to typeof(double), TimeGenerated to typeof(datetime), anomalies to typeof(double),score to typeof(double), baseline to typeof(long)\n| where anomalies > 0 | extend AnomalyHour = TimeGenerated\n| where baseline > baselinethreshold // Filtering low count events per baselinethreshold\n| project Resource, AnomalyHour, TimeGenerated, HourlyCount, baseline, anomalies, score;\nlet AnomalyHours = TimeSeriesAlerts | where TimeGenerated > ago(2d) | project TimeGenerated;\n// Filter the alerts since specified timeframe\nTimeSeriesAlerts\n| where TimeGenerated > ago(2d)\n// Join against base logs since specified timeframe to retrive records associated with the hour of anomoly\n| join (\nAzureDiagnostics\n| where TimeGenerated > ago(timeframe)\n| where not((identity_claim_appid_g in (Allowedappid)) and OperationName == 'VaultGet')\n| extend DateHour = bin(TimeGenerated, 1h) // create a new column and round to hour\n| where DateHour in ((AnomalyHours)) //filter the dataset to only selected anomaly hours\n| extend ResultType = columnifexists(\"ResultType\", \"NoResultType\")\n| extend requestUri_s = columnifexists(\"requestUri_s\", \"None\"), identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g = columnifexists(\"identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g\", \"None\")\n| extend id_s = columnifexists(\"id_s\", \"None\"), CallerIPAddress = columnifexists(\"CallerIPAddress\", \"None\"), clientInfo_s = columnifexists(\"clientInfo_s\", \"None\")\n| where ResultType !~ \"None\" and isnotempty(ResultType)\n| where identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g !~ \"None\" and isnotempty(identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g)\n| where id_s !~ \"None\" and isnotempty(id_s)\n| where CallerIPAddress !~ \"None\" and isnotempty(CallerIPAddress)\n| where clientInfo_s !~ \"None\" and isnotempty(clientInfo_s)\n| where requestUri_s !~ \"None\" and isnotempty(requestUri_s)\n| where ResourceType =~ \"VAULTS\" and ResultType =~ \"Success\"\n| where OperationName in (OperationList)\n| 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, requestUri_s, clientInfo_s\n) on Resource, TimeGenerated\n| summarize EventCount=count(), OperationNameList = make_set(OperationName), RequestURLList = make_set(requestUri_s, 100), AccountList = make_set(identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g, 100), AccountMax = arg_max(identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g,*) by Resource, id_s, clientInfo_s, LatestAnomalyTime\n| extend timestamp = LatestAnomalyTime, IPCustomEntity = CallerIPAddress, AccountCustomEntity = AccountMax\n",
"queryFrequency": "P1D",
"queryPeriod": "P14D",
"triggerOperator": "GreaterThan",
"triggerThreshold": 0,
"suppressionDuration": "PT1H",
"suppressionEnabled": false,
"tactics": [
"CredentialAccess"
],
"techniques": [
"T1003"
],
"alertRuleTemplateName": "0914adab-90b5-47a3-a79f-7cdcac843aa7",
"customDetails": null,
"entityMappings": [
{
"entityType": "Account",
"fieldMappings": [
{
"identifier": "FullName",
"columnName": "AccountCustomEntity"
}
]
},
{
"entityType": "IP",
"fieldMappings": [
{
"identifier": "Address",
"columnName": "IPCustomEntity"
}
]
}
],
"status": "Available",
"OriginalUri": "https://github.com/Azure/Azure-Sentinel/blob/master/Solutions/Azure Key Vault/Analytic Rules/TimeSeriesKeyvaultAccessAnomaly.yaml",
"templateVersion": "1.0.3"
}
}
]
}