Microsoft Sentinel Analytic Rules
cloudbrothers.infoAzure Sentinel RepoToggle Dark/Light/Auto modeToggle Dark/Light/Auto modeToggle Dark/Light/Auto modeBack to homepage

Azure Key Vault access TimeSeries anomaly

Back
Id0914adab-90b5-47a3-a79f-7cdcac843aa7
RulenameAzure Key Vault access TimeSeries anomaly
DescriptionIdentifies 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
SeverityLow
TacticsCredentialAccess
TechniquesT1003
Required data connectorsAzureKeyVault
KindScheduled
Query frequency1d
Query period14d
Trigger threshold0
Trigger operatorgt
Source Urihttps://github.com/Azure/Azure-Sentinel/blob/master/Solutions/Azure Key Vault/Analytic Rules/TimeSeriesKeyvaultAccessAnomaly.yaml
Version1.0.6
Arm template0914adab-90b5-47a3-a79f-7cdcac843aa7.json
Deploy To Azure
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
relevantTechniques:
- T1003
name: Azure Key Vault access TimeSeries anomaly
requiredDataConnectors:
- dataTypes:
  - KeyVaultData
  connectorId: AzureKeyVault
entityMappings:
- fieldMappings:
  - identifier: Name
    columnName: AccountMax
  entityType: Account
- fieldMappings:
  - identifier: Address
    columnName: CallerIPAddress
  entityType: IP
triggerThreshold: 0
id: 0914adab-90b5-47a3-a79f-7cdcac843aa7
tactics:
- CredentialAccess
version: 1.0.6
OriginalUri: https://github.com/Azure/Azure-Sentinel/blob/master/Solutions/Azure Key Vault/Analytic Rules/TimeSeriesKeyvaultAccessAnomaly.yaml
queryPeriod: 14d
kind: Scheduled
queryFrequency: 1d
severity: Low
status: Available
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'  
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  
triggerOperator: gt
{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "workspace": {
      "type": "String"
    }
  },
  "resources": [
    {
      "apiVersion": "2024-01-01-preview",
      "id": "[concat(resourceId('Microsoft.OperationalInsights/workspaces/providers', parameters('workspace'), 'Microsoft.SecurityInsights'),'/alertRules/0914adab-90b5-47a3-a79f-7cdcac843aa7')]",
      "kind": "Scheduled",
      "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/0914adab-90b5-47a3-a79f-7cdcac843aa7')]",
      "properties": {
        "alertRuleTemplateName": "0914adab-90b5-47a3-a79f-7cdcac843aa7",
        "customDetails": null,
        "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.\nAny 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.\nTimeSeries Reference Blog: https://techcommunity.microsoft.com/t5/azure-sentinel/looking-for-unknown-anomalies-what-is-normal-time-series/ba-p/555052'\n",
        "displayName": "Azure Key Vault access TimeSeries anomaly",
        "enabled": true,
        "entityMappings": [
          {
            "entityType": "Account",
            "fieldMappings": [
              {
                "columnName": "AccountMax",
                "identifier": "Name"
              }
            ]
          },
          {
            "entityType": "IP",
            "fieldMappings": [
              {
                "columnName": "CallerIPAddress",
                "identifier": "Address"
              }
            ]
          }
        ],
        "OriginalUri": "https://github.com/Azure/Azure-Sentinel/blob/master/Solutions/Azure Key Vault/Analytic Rules/TimeSeriesKeyvaultAccessAnomaly.yaml",
        "query": "let starttime = 14d;\nlet timeframe = 1d;\nlet scorethreshold = 3;\nlet baselinethreshold = 25;\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  | where ResourceType =~ \"VAULTS\" and ResultType =~ \"Success\"\n| where OperationName in (OperationList)\n| extend ResultType = column_ifexists(\"ResultType\", \"None\"), CallerIPAddress = column_ifexists(\"CallerIPAddress\", \"None\")\n| where ResultType !~ \"None\" and isnotempty(ResultType)\n| where CallerIPAddress !~ \"None\" and isnotempty(CallerIPAddress)\n| project TimeGenerated, OperationName, Resource, CallerIPAddress\n| make-series HourlyCount=count() on TimeGenerated from startofday(ago(starttime)) to startofday(now()) step timeframe by CallerIPAddress;\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 CallerIPAddress, 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 kind = innerunique (\nAzureDiagnostics\n| where TimeGenerated > ago(2d)\n| where not((identity_claim_appid_g in (Allowedappid)) and OperationName == 'VaultGet')\n| where ResourceType =~ \"VAULTS\" and ResultType =~ \"Success\"\n| where OperationName in (OperationList)\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 = column_ifexists(\"ResultType\", \"NoResultType\")\n| 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\", \"\"),\n  identity_claim_upn_s = column_ifexists(\"identity_claim_upn_s\", \"\")\n| extend\n    CallerObjectId = iff(isempty(identity_claim_oid_g), identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g, identity_claim_oid_g),\n    CallerObjectUPN = iff(isempty(identity_claim_upn_s), identity_claim_http_schemas_xmlsoap_org_ws_2005_05_identity_claims_upn_s, identity_claim_upn_s)\n| extend id_s = column_ifexists(\"id_s\", \"None\"), CallerIPAddress = column_ifexists(\"CallerIPAddress\", \"None\"), clientInfo_s = column_ifexists(\"clientInfo_s\", \"None\")\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, identity_claim_oid_g, requestUri_s, clientInfo_s\n) on CallerIPAddress\n| extend\n    CallerObjectId = iff(isempty(identity_claim_oid_g), identity_claim_http_schemas_microsoft_com_identity_claims_objectidentifier_g, identity_claim_oid_g),\n    CallerObjectUPN = iff(isempty(identity_claim_upn_s), identity_claim_http_schemas_xmlsoap_org_ws_2005_05_identity_claims_upn_s, identity_claim_upn_s)\n| 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\n| extend timestamp = LatestAnomalyTime\n",
        "queryFrequency": "P1D",
        "queryPeriod": "P14D",
        "severity": "Low",
        "status": "Available",
        "subTechniques": [],
        "suppressionDuration": "PT1H",
        "suppressionEnabled": false,
        "tactics": [
          "CredentialAccess"
        ],
        "techniques": [
          "T1003"
        ],
        "templateVersion": "1.0.6",
        "triggerOperator": "GreaterThan",
        "triggerThreshold": 0
      },
      "type": "Microsoft.OperationalInsights/workspaces/providers/alertRules"
    }
  ]
}