From d698d19cb9a5cb1217b7e5c0a20f59881e34c967 Mon Sep 17 00:00:00 2001 From: Ricardo Valdes Date: Tue, 29 Sep 2026 13:57:18 -0400 Subject: [PATCH] fix(azure): one admission webhook alert per cluster and identity The AKS control plane (aksService) patches its own admission webhooks (the AKS node webhooks, Azure Policy and Gatekeeper) every few minutes from changing internal addresses. "Azure Kubernetes Admission Webhook Modified" grouped by six keys including the source address, so it kept opening new parents and stored a child alert for every patch, and the rule flood guard switched it off. The rule now raises one alert per data source, cluster (azureScope) and identity, and drops repeats for seven days (deduplicateBy). Its condition is unchanged, so an administrator or any other identity that creates or changes a webhook still alerts. azure_alert_volume_test.go pins the keys and checks that they resolve on fabricated AKS audit records. Co-Authored-By: Claude Opus 5.5 --- plugins/alerts/azure_alert_volume_test.go | 80 +++++++++++++++++++ .../azure_kubernetes_admission_controller.yml | 9 +-- 2 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 plugins/alerts/azure_alert_volume_test.go diff --git a/plugins/alerts/azure_alert_volume_test.go b/plugins/alerts/azure_alert_volume_test.go new file mode 100644 index 000000000..af94ea1d8 --- /dev/null +++ b/plugins/alerts/azure_alert_volume_test.go @@ -0,0 +1,80 @@ +package main + +// Fabricated AKS audit records run through the offline Azure parser model +// (azureParse) and the pinned SDK CEL. They pin the de-duplication keys that +// keep the admission webhook rule from alerting on every reconciliation patch +// the AKS control plane makes. The EventProcessor playground separately runs +// the real parser and alert plugins. +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/threatwinds/go-sdk/plugins" + "github.com/tidwall/gjson" +) + +func azureVolumeAudit(t *testing.T, user, verb, stage, resource, name string, code int) string { + t.Helper() + audit, err := json.Marshal(map[string]any{ + "kind": "Event", "apiVersion": "audit.k8s.io/v1", "stage": stage, "verb": verb, + "user": map[string]any{"username": user}, "sourceIPs": []string{"198.51.100.4"}, + "responseStatus": map[string]any{"code": code}, + "objectRef": map[string]any{"resource": resource, "name": name, "apiGroup": "admissionregistration.k8s.io"}, + "requestURI": "/apis/admissionregistration.k8s.io/v1/" + resource + "/" + name + "?fieldManager=example", + }) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(map[string]any{ + "time": "2026-09-29T10:00:00Z", "tenantId": "directory-test", "category": "kube-audit-admin", + "resourceId": "/SUBSCRIPTIONS/00000000-0000-4000-8000-000000000000/RESOURCEGROUPS/RG-TEST/PROVIDERS/MICROSOFT.CONTAINERSERVICE/MANAGEDCLUSTERS/AKS-TEST", + "operationName": "Microsoft.ContainerService/managedClusters/diagnosticLogs/Read", + "properties": map[string]any{"log": string(audit)}, + }) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +func TestAzureKubernetesWebhookAlertVolume(t *testing.T) { + cfg, cache := azureConfig(t), plugins.NewCELCache("azure-alert-volume") + r := azureRules(t)["azure_kubernetes_admission_controller"] + if r == nil { + t.Fatal("missing azure_kubernetes_admission_controller") + } + want := []string{"dataSource", "lastEvent.log.azureScope", "adversary.user"} + if len(r.GroupBy) != 0 || !reflect.DeepEqual(r.DeduplicateBy, want) { + t.Fatalf("grouping got groupBy %v deduplicateBy %v, want deduplicateBy %v", r.GroupBy, r.DeduplicateBy, want) + } + for _, tc := range []struct { + name string + raw string + want bool + }{ + {"control plane patch", azureVolumeAudit(t, "aksService", "patch", "ResponseComplete", "validatingwebhookconfigurations", "aks-node-validating-webhook", 200), true}, + {"administrator create", azureVolumeAudit(t, "admin@example.test", "create", "ResponseComplete", "mutatingwebhookconfigurations", "inject-sidecar", 201), true}, + {"read", azureVolumeAudit(t, "aksService", "get", "ResponseComplete", "validatingwebhookconfigurations", "aks-node-validating-webhook", 200), false}, + {"request received", azureVolumeAudit(t, "admin@example.test", "create", "RequestReceived", "mutatingwebhookconfigurations", "inject-sidecar", 200), false}, + {"denied", azureVolumeAudit(t, "admin@example.test", "patch", "ResponseComplete", "mutatingwebhookconfigurations", "inject-sidecar", 403), false}, + {"other resource", azureVolumeAudit(t, "admin@example.test", "patch", "ResponseComplete", "configmaps", "settings", 200), false}, + } { + t.Run(tc.name, func(t *testing.T) { + out := azureParse(t, cfg, tc.raw, "EventHub (test)", cache) + got, err := cache.Eval(r.Where, out) + if err != nil || got != tc.want { + t.Fatalf("where got %v (%v), want %v for %s", got, err, tc.want, out) + } + if !tc.want { + return + } + // adversary: origin, so adversary.user is the event's origin.user. + for key, path := range map[string]string{"dataSource": "dataSource", "lastEvent.log.azureScope": "log.azureScope", "adversary.user": "origin.user"} { + if v := gjson.Get(out, path); v.Type != gjson.String || v.String() == "" { + t.Fatalf("de-duplication key %s (%s) does not resolve to text in %s", key, path, out) + } + } + }) + } +} diff --git a/rules/cloud/azure/azure_kubernetes_admission_controller.yml b/rules/cloud/azure/azure_kubernetes_admission_controller.yml index 67c121061..586666788 100644 --- a/rules/cloud/azure/azure_kubernetes_admission_controller.yml +++ b/rules/cloud/azure/azure_kubernetes_admission_controller.yml @@ -15,6 +15,8 @@ references: - https://attack.mitre.org/techniques/T1078/004/ description: 'Detects creation or modification of MutatingAdmissionWebhook or ValidatingAdmissionWebhook configurations in Azure Kubernetes Service. Attackers use admission controllers to inject malicious containers or modify workload specifications. + The AKS control plane (aksService) patches its own webhooks every few minutes, so one alert is raised per cluster and + identity; repeats are suppressed for seven days. Next Steps: @@ -37,10 +39,7 @@ where: 'equalsIgnoreCase("log.azureKind","kubernetes") && equalsIgnoreCase("log. "update", "patch"]) ' -groupBy: -- lastEvent.dataSource -- lastEvent.log.azureScopeType +deduplicateBy: +- dataSource - lastEvent.log.azureScope -- lastEvent.log.azureOperation - adversary.user -- adversary.ip