From 78f7030f6bc76569544125303840ab6885b4b62b Mon Sep 17 00:00:00 2001 From: Yadian Llada Lopez Date: Thu, 20 Aug 2026 14:22:56 -0400 Subject: [PATCH] fix(rule-flood-guard): notify only the affected tenant, without naming it The flood notification went to the tenant whose rule was disabled and also to the platform tenant, so the operator would keep the instance-wide visibility they had before the disable became per-tenant. In an MSSP that turns every flood in every tenant into a ping on the operator's bell, which is the same alert fatigue this plugin exists to prevent. The tenant is the one who can act on it, so the copy is gone. With no operator copy left, naming the tenant in the message was noise: the only reader is the affected tenant, and showing them their own UUID tells them nothing. The message now speaks to them directly. The tenant is still in the structured log, which is where operators read from. --- plugins/rule-flood-guard/README.md | 7 +++---- plugins/rule-flood-guard/backend.go | 25 ++++++------------------- plugins/rule-flood-guard/guard.go | 2 +- 3 files changed, 10 insertions(+), 24 deletions(-) diff --git a/plugins/rule-flood-guard/README.md b/plugins/rule-flood-guard/README.md index e1ee6c422..b107401d5 100644 --- a/plugins/rule-flood-guard/README.md +++ b/plugins/rule-flood-guard/README.md @@ -17,10 +17,9 @@ Everything is scoped per tenant: are each below it never add up to a flood between them. - **Disabling.** The rule is disabled only for the tenant that flooded. Every other tenant keeps it running. -- **Notifying.** The offending tenant is notified, since the disable applies to - them and the remediation is theirs to apply. The platform tenant receives a - copy so the operator keeps instance-wide visibility — unless it is already - the offending tenant, in which case a single notification is sent. +- **Notifying.** Only the tenant that flooded is notified, since the disable + applies to them and the remediation is theirs to apply. No other tenant hears + about it. Alerts that carry no tenant are dropped rather than attributed to a default tenant. diff --git a/plugins/rule-flood-guard/backend.go b/plugins/rule-flood-guard/backend.go index c199d14e4..00da9e9f1 100644 --- a/plugins/rule-flood-guard/backend.go +++ b/plugins/rule-flood-guard/backend.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "io" "net/http" @@ -15,9 +14,10 @@ import ( "github.com/threatwinds/go-sdk/catcher" ) -// The window is interpolated rather than hardcoded: windowHours is -// configurable, so a fixed "24h" would misreport the period the count covers. -const notificationMessageTemplate = "Correlation rule '%s' generated %d open, un-deduplicated alerts from data source '%s' for tenant '%s' in the last %dh and was automatically disabled for that tenant to prevent alert flooding. If this volume is expected, use an Alert Tag Rule to mark it 'False positive', or add deduplicateBy/groupBy to the rule, then re-enable it." +// Template for the notification message sent to the tenant that flooded. It is +// formatted with the rule name, the number of alerts, the data source, and the +// window in hours. +const notificationMessageTemplate = "Correlation rule '%s' generated %d open, un-deduplicated alerts from data source '%s' in the last %dh and was automatically disabled to prevent alert flooding. If this volume is expected, use an Alert Tag Rule to mark it 'False positive', or add deduplicateBy/groupBy to the rule, then re-enable it." // tenantHeader scopes every backend call. Without it the middleware treats an // internal caller as tenantless and the backend falls back to the platform @@ -154,20 +154,7 @@ type notifyRequest struct { Message string `json:"message"` } -// platformTenant is the operator's tenant. It gets a copy of every flood -// notification so the operator keeps the instance-wide visibility they had -// before the disable became per-tenant. -const platformTenant = "ce66672c-e36d-4761-a8c8-90058fee1a24" - func (c *backendClient) Notify(ctx context.Context, tenantID, message string) error { - err := c.notifyTenant(ctx, tenantID, message) - if tenantID != platformTenant { - err = errors.Join(err, c.notifyTenant(ctx, platformTenant, message)) - } - return err -} - -func (c *backendClient) notifyTenant(ctx context.Context, tenantID, message string) error { payload, err := json.Marshal(notifyRequest{Source: "SYSTEM", Type: "WARNING", Message: message}) if err != nil { return err @@ -196,6 +183,6 @@ func (c *backendClient) notifyTenant(ctx context.Context, tenantID, message stri return nil } -func floodNotificationMessage(tenantID, ruleName string, count int64, dataSource string, windowHours int) string { - return fmt.Sprintf(notificationMessageTemplate, ruleName, count, dataSource, tenantID, windowHours) +func floodNotificationMessage(ruleName string, count int64, dataSource string, windowHours int) string { + return fmt.Sprintf(notificationMessageTemplate, ruleName, count, dataSource, windowHours) } diff --git a/plugins/rule-flood-guard/guard.go b/plugins/rule-flood-guard/guard.go index e870facef..cc67a4b98 100644 --- a/plugins/rule-flood-guard/guard.go +++ b/plugins/rule-flood-guard/guard.go @@ -50,7 +50,7 @@ func evaluateOnce(ctx context.Context, search searchFunc, client disableNotifier continue } - msg := floodNotificationMessage(b.TenantID, b.RuleName, b.Count, b.DataSource, cfg.WindowHours) + msg := floodNotificationMessage(b.RuleName, b.Count, b.DataSource, cfg.WindowHours) if err := client.Notify(ctx, b.TenantID, msg); err != nil { _ = catcher.Error("rule-flood-guard: failed to send notification", err, map[string]any{ "tenantId": b.TenantID, "ruleName": b.RuleName, "dataSource": b.DataSource,