diff --git a/.github/workflows/v12-deployment-pipeline.yml b/.github/workflows/v12-deployment-pipeline.yml index 1e2d3f36a..0942a127e 100644 --- a/.github/workflows/v12-deployment-pipeline.yml +++ b/.github/workflows/v12-deployment-pipeline.yml @@ -391,6 +391,7 @@ jobs: - ad-audit - soar - playground + - rule-flood-guard steps: - name: Check out code into the right branch uses: actions/checkout@v4 diff --git a/backend/modules/eventprocessing/dto/correlation_rule_internal.go b/backend/modules/eventprocessing/dto/correlation_rule_internal.go new file mode 100644 index 000000000..0976adcb4 --- /dev/null +++ b/backend/modules/eventprocessing/dto/correlation_rule_internal.go @@ -0,0 +1,9 @@ +package dto + +type InternalDeactivateRuleRequest struct { + RuleName string `json:"ruleName" binding:"required"` +} + +type InternalDeactivateRuleResponse struct { + Changed bool `json:"changed"` +} diff --git a/backend/modules/eventprocessing/handler/correlation_rule_internal.go b/backend/modules/eventprocessing/handler/correlation_rule_internal.go new file mode 100644 index 000000000..fb85c06cd --- /dev/null +++ b/backend/modules/eventprocessing/handler/correlation_rule_internal.go @@ -0,0 +1,65 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/utmstack/utmstack/backend/modules/eventprocessing/dto" +) + +// @Summary Deactivate a correlation rule by name (internal) +// @Tags Correlation Rules +// @Accept json +// @Produce json +// @Param input body dto.InternalDeactivateRuleRequest true "Rule to deactivate" +// @Success 200 {object} dto.InternalDeactivateRuleResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /eventprocessing/internal/correlation-rule/deactivate [put] +func (h *CorrelationRuleHandler) InternalDeactivate(c *gin.Context) { + var req dto.InternalDeactivateRuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx := c.Request.Context() + + // RuleName filtering in List is a case-insensitive partial match, so the + // exact (case-insensitive) match is re-applied here to avoid disabling + // unrelated rules whose name merely contains the requested substring. + result, err := h.usecase.List(ctx, dto.CorrelationRuleFilters{RuleName: req.RuleName}) + if err != nil { + writeCorrelationError(c, err) + return + } + + var matches []dto.CorrelationRuleResponse + for _, r := range result.Items { + if strings.EqualFold(r.RuleName, req.RuleName) { + matches = append(matches, r) + } + } + if len(matches) == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "correlation rule not found"}) + return + } + + // Two or more rules sharing an exact display name is an accepted edge + // case (see design's Open Questions) — disable every exact match. + changed := false + for _, r := range matches { + if !r.RuleActive { + continue + } + if err := h.usecase.SetActive(ctx, r.RelPath, false); err != nil { + writeCorrelationError(c, err) + return + } + changed = true + } + + c.JSON(http.StatusOK, dto.InternalDeactivateRuleResponse{Changed: changed}) +} diff --git a/backend/modules/eventprocessing/routes.go b/backend/modules/eventprocessing/routes.go index 863464132..6fcd6b9bb 100644 --- a/backend/modules/eventprocessing/routes.go +++ b/backend/modules/eventprocessing/routes.go @@ -38,6 +38,11 @@ func RegisterRoutes(api *gin.RouterGroup, m *Module, userAuth gin.HandlerFunc) { cr.GET("/find", read, crh.GetByID) cr.DELETE("", write, crh.Delete) + // Internal-only: rule-flood-guard plugin disables a rule it identified as + // flooding the alerts list, by display name. + icr := g.Group("/internal/correlation-rule", middleware.RequireInternal()) + icr.PUT("/deactivate", crh.InternalDeactivate) + // Filters (file-backed, pipeline: YAML). Identity = relPath query param. f := g.Group("/filters") f.POST("", write, fh.Create) diff --git a/backend/modules/notifications/domain/enums.go b/backend/modules/notifications/domain/enums.go index fffce4717..01ed3d7f5 100644 --- a/backend/modules/notifications/domain/enums.go +++ b/backend/modules/notifications/domain/enums.go @@ -18,7 +18,7 @@ const ( func (s NotificationSource) Valid() bool { switch s { case SourceAS400, SourceBitDefender, SourceAWS, SourceAzure, - SourceOffice365, SourceSophos, SourceGoogle, SourceEmailSetting, SourceAlerts: + SourceOffice365, SourceSophos, SourceGoogle, SourceEmailSetting, SourceAlerts, SourceSystem: return true } return false diff --git a/backend/modules/notifications/dto/notification.go b/backend/modules/notifications/dto/notification.go index d9a920270..d9e29e00b 100644 --- a/backend/modules/notifications/dto/notification.go +++ b/backend/modules/notifications/dto/notification.go @@ -7,7 +7,7 @@ import ( ) type CreateNotificationRequest struct { - Source domain.NotificationSource `json:"source" binding:"required,oneof=AS400 BIT_DEFENDER AWS AZURE OFFICE_365 SOPHOS GOOGLE EMAIL_SETTING ALERTS"` + Source domain.NotificationSource `json:"source" binding:"required,oneof=AS400 BIT_DEFENDER AWS AZURE OFFICE_365 SOPHOS GOOGLE EMAIL_SETTING ALERTS SYSTEM"` Type domain.NotificationType `json:"type" binding:"required,oneof=INFO WARNING ERROR"` Message string `json:"message" binding:"required,min=1"` } diff --git a/plugins/rule-flood-guard/README.md b/plugins/rule-flood-guard/README.md new file mode 100644 index 000000000..edc5eeb37 --- /dev/null +++ b/plugins/rule-flood-guard/README.md @@ -0,0 +1,35 @@ +# UTMStack Rule Flood Guard Plugin + +Safety net against alert fatigue: when a correlation rule floods the alerts +list with individual, non-deduplicated alerts, this plugin automatically +disables the rule and notifies the user through the notification bell, +suggesting they mark the alerts as false positives (Alert Tag Rules) or +fine-tune the rule (`deduplicateBy`/`groupBy`). + +It runs as its own independent plugin, separate from `plugins/alerts`. + +## Configuration + +Settings live in `system_plugins_rule-flood-guard.yaml`, in the same pipeline +config folder as the other plugins' config files. It reloads automatically +when the file changes — no restart needed. + +```yaml +plugins: + rule-flood-guard: + enabled: true + threshold: 50 + windowHours: 24 + intervalSeconds: 300 +``` + +| Field | Default | Meaning | +|---|---|---| +| `enabled` | `true` | Turns the guard on or off. | +| `threshold` | `50` | How many alerts trigger the auto-disable. | +| `windowHours` | `24` | Time window used to count alerts. | +| `intervalSeconds` | `300` | How often the guard checks. | + +If the file doesn't exist, the guard just runs with these defaults. + + diff --git a/plugins/rule-flood-guard/aggregation.go b/plugins/rule-flood-guard/aggregation.go new file mode 100644 index 000000000..e26617b4f --- /dev/null +++ b/plugins/rule-flood-guard/aggregation.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "encoding/json" + "time" + + sdkos "github.com/threatwinds/go-sdk/os" +) + +const alertIndex = "v11-alert-*" + +const falsePositiveTag = "False positive" + +const ruleNameAgg = "by_rule_name" + +type ruleBucket struct { + RuleName string + Count int64 +} + +func buildFloodQuery(window time.Duration, now time.Time) map[string]any { + gte := now.Add(-window).UTC().Format(time.RFC3339) + return map[string]any{ + "size": 0, + "query": map[string]any{ + "bool": map[string]any{ + "must": []map[string]any{ + {"term": map[string]any{"status": 2}}, + {"range": map[string]any{"@timestamp": map[string]any{"gte": gte}}}, + }, + "must_not": []map[string]any{ + {"term": map[string]any{"tags": falsePositiveTag}}, + {"exists": map[string]any{"field": "parentId.keyword"}}, + }, + }, + }, + "aggs": map[string]any{ + ruleNameAgg: map[string]any{ + "terms": map[string]any{ + "field": "name.keyword", + "size": 1000, + }, + }, + }, + } +} + +type termsBucketsResponse struct { + Buckets []struct { + Key string `json:"key"` + DocCount int64 `json:"doc_count"` + } `json:"buckets"` +} + +func parseRuleBuckets(aggs map[string]interface{}) ([]ruleBucket, error) { + raw, ok := aggs[ruleNameAgg] + if !ok { + return nil, nil + } + b, err := json.Marshal(raw) + if err != nil { + return nil, err + } + var parsed termsBucketsResponse + if err := json.Unmarshal(b, &parsed); err != nil { + return nil, err + } + buckets := make([]ruleBucket, 0, len(parsed.Buckets)) + for _, bucket := range parsed.Buckets { + buckets = append(buckets, ruleBucket{RuleName: bucket.Key, Count: bucket.DocCount}) + } + return buckets, nil +} + +func searchRuleBuckets(ctx context.Context, window time.Duration) ([]ruleBucket, error) { + query := buildFloodQuery(window, time.Now()) + result, err := sdkos.RawSearch(ctx, []string{alertIndex}, query) + if err != nil { + return nil, err + } + return parseRuleBuckets(result.Aggregations) +} diff --git a/plugins/rule-flood-guard/backend.go b/plugins/rule-flood-guard/backend.go new file mode 100644 index 000000000..74fca91ed --- /dev/null +++ b/plugins/rule-flood-guard/backend.go @@ -0,0 +1,108 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/threatwinds/go-sdk/catcher" +) + +const notificationMessageTemplate = "Correlation rule '%s' generated over %d open, un-deduplicated alerts in the last 24h 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." + +type backendClient struct { + baseURL string + internalKey string + httpClient *http.Client +} + +func newBackendClient(baseURL, internalKey string) *backendClient { + return &backendClient{ + baseURL: baseURL, + internalKey: internalKey, + httpClient: &http.Client{Timeout: 15 * time.Second}, + } +} + +type deactivateRequest struct { + RuleName string `json:"ruleName"` +} + +type deactivateResponse struct { + Changed bool `json:"changed"` +} + +func (c *backendClient) Deactivate(ctx context.Context, ruleName string) (bool, error) { + payload, err := json.Marshal(deactivateRequest{RuleName: ruleName}) + if err != nil { + return false, err + } + url := c.baseURL + "/api/v1/eventprocessing/internal/correlation-rule/deactivate" + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(payload)) + if err != nil { + return false, err + } + req.Header.Set("X-Internal-Key", c.internalKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return false, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return false, catcher.Error("deactivate call returned error status", nil, map[string]any{ + "status": resp.StatusCode, "body": string(body), "ruleName": ruleName, + }) + } + + var out deactivateResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return false, err + } + return out.Changed, nil +} + +type notifyRequest struct { + Source string `json:"source"` + Type string `json:"type"` + Message string `json:"message"` +} + +func (c *backendClient) Notify(ctx context.Context, message string) error { + payload, err := json.Marshal(notifyRequest{Source: "SYSTEM", Type: "WARNING", Message: message}) + if err != nil { + return err + } + url := c.baseURL + "/api/v1/notifications" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("X-Internal-Key", c.internalKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return catcher.Error("notify call returned error status", nil, map[string]any{ + "status": resp.StatusCode, "body": string(body), + }) + } + return nil +} + +func floodNotificationMessage(ruleName string, threshold int64) string { + return fmt.Sprintf(notificationMessageTemplate, ruleName, threshold) +} diff --git a/plugins/rule-flood-guard/config.go b/plugins/rule-flood-guard/config.go new file mode 100644 index 000000000..6a4754450 --- /dev/null +++ b/plugins/rule-flood-guard/config.go @@ -0,0 +1,206 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/threatwinds/go-sdk/catcher" + "github.com/threatwinds/go-sdk/plugins" + "gopkg.in/yaml.v3" +) + +type Config struct { + Enabled bool + Threshold int64 + WindowHours int + IntervalSeconds int + BackendURL string + InternalKey string +} + +const ( + pluginKey = "rule-flood-guard" + configFileName = "system_plugins_rule-flood-guard.yaml" + pipelineDirDefault = "/workdir/pipeline" + processName = "plugin_com.utmstack.rule-flood-guard" + + defaultEnabled = true + defaultThreshold int64 = 50 + defaultWindowHours = 24 + defaultIntervalSeconds = 300 +) + +type fileConfig struct { + Enabled *bool `yaml:"enabled"` + Threshold *int64 `yaml:"threshold"` + WindowHours *int `yaml:"windowHours"` + IntervalSeconds *int `yaml:"intervalSeconds"` +} + +type pluginsFile struct { + Plugins map[string]fileConfig `yaml:"plugins"` +} + +func defaultKnobs() Config { + return Config{ + Enabled: defaultEnabled, + Threshold: defaultThreshold, + WindowHours: defaultWindowHours, + IntervalSeconds: defaultIntervalSeconds, + } +} + +func applyFileConfig(base Config, fc fileConfig) Config { + if fc.Enabled != nil { + base.Enabled = *fc.Enabled + } + if fc.Threshold != nil && *fc.Threshold > 0 { + base.Threshold = *fc.Threshold + } + if fc.WindowHours != nil && *fc.WindowHours > 0 { + base.WindowHours = *fc.WindowHours + } + if fc.IntervalSeconds != nil && *fc.IntervalSeconds > 0 { + base.IntervalSeconds = *fc.IntervalSeconds + } + return base +} + +func loadKnobsFromFile(path string) (Config, error) { + knobs := defaultKnobs() + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return knobs, nil + } + return knobs, err + } + + var pf pluginsFile + if err := yaml.Unmarshal(data, &pf); err != nil { + return knobs, err + } + fc, ok := pf.Plugins[pluginKey] + if !ok { + return knobs, nil + } + return applyFileConfig(knobs, fc), nil +} + +type configHolder struct { + mu sync.RWMutex + cfg Config +} + +func newConfigHolder(initial Config) *configHolder { + return &configHolder{cfg: initial} +} + +func (h *configHolder) Get() Config { + h.mu.RLock() + defer h.mu.RUnlock() + return h.cfg +} + +func (h *configHolder) setKnobs(knobs Config) { + h.mu.Lock() + defer h.mu.Unlock() + h.cfg.Enabled = knobs.Enabled + h.cfg.Threshold = knobs.Threshold + h.cfg.WindowHours = knobs.WindowHours + h.cfg.IntervalSeconds = knobs.IntervalSeconds +} + +func loadConfig() (Config, string) { + cfg := plugins.PluginCfg("com.utmstack") + backendURL := normalizeBackendURL(cfg.Get("backend").String()) + internalKey := cfg.Get("internalKey").String() + pipelineDir := cfg.Get("pipelineDir").String() + if pipelineDir == "" { + pipelineDir = pipelineDirDefault + } + + path := filepath.Join(pipelineDir, configFileName) + knobs, err := loadKnobsFromFile(path) + if err != nil { + _ = catcher.Error("rule-flood-guard: failed to read config file, using defaults", err, map[string]any{"process": processName, "file": path}) + knobs = defaultKnobs() + } + knobs.BackendURL = backendURL + knobs.InternalKey = internalKey + return knobs, path +} + +func normalizeBackendURL(raw string) string { + if raw == "" { + return raw + } + if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") { + return raw + } + return "http://" + raw +} + +func watchConfigFile(ctx context.Context, holder *configHolder, path string) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + _ = catcher.Error("rule-flood-guard: failed to create config file watcher, hot-reload disabled", err, map[string]any{"process": processName}) + return + } + defer func() { _ = watcher.Close() }() + + dir := filepath.Dir(path) + if err := watcher.Add(dir); err != nil { + _ = catcher.Error("rule-flood-guard: failed to watch config dir, hot-reload disabled", err, map[string]any{"process": processName, "dir": dir}) + return + } + + catcher.Info("rule-flood-guard: watching "+path+" for config changes", map[string]any{"process": processName}) + + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Name != path { + continue + } + if !(event.Has(fsnotify.Write) || event.Has(fsnotify.Create) || + event.Has(fsnotify.Rename) || event.Has(fsnotify.Remove)) { + continue + } + knobs, err := loadKnobsFromFile(path) + if err != nil { + _ = catcher.Error("rule-flood-guard: failed to reload config file, keeping previous values", err, map[string]any{"process": processName, "file": path}) + continue + } + holder.setKnobs(knobs) + catcher.Info("rule-flood-guard: config reloaded", map[string]any{ + "process": processName, "enabled": knobs.Enabled, "threshold": knobs.Threshold, + "windowHours": knobs.WindowHours, "intervalSeconds": knobs.IntervalSeconds, + }) + case err, ok := <-watcher.Errors: + if !ok { + return + } + _ = catcher.Error("rule-flood-guard: config watcher error", err, map[string]any{"process": processName}) + case <-ctx.Done(): + return + } + } +} + +func (c Config) tickInterval() time.Duration { + return time.Duration(c.IntervalSeconds) * time.Second +} + +func (c Config) window() time.Duration { + return time.Duration(c.WindowHours) * time.Hour +} diff --git a/plugins/rule-flood-guard/go.mod b/plugins/rule-flood-guard/go.mod new file mode 100644 index 000000000..a3d337680 --- /dev/null +++ b/plugins/rule-flood-guard/go.mod @@ -0,0 +1,58 @@ +module github.com/utmstack/UTMStack/plugins/rule-flood-guard + +go 1.25.5 + +require ( + github.com/fsnotify/fsnotify v1.10.1 + github.com/threatwinds/go-sdk v1.1.26 + google.golang.org/protobuf v1.36.11 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + cel.dev/expr v0.25.2 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/bytedance/gopkg v0.1.4 // indirect + github.com/bytedance/sonic v1.15.2 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect + github.com/cloudwego/base64x v0.1.7 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gin-contrib/sse v1.1.1 // indirect + github.com/gin-gonic/gin v1.12.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/cel-go v0.28.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/opensearch-project/opensearch-go/v4 v4.6.0 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.1 // indirect + github.com/tidwall/gjson v1.19.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.27.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.81.1 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/plugins/rule-flood-guard/go.sum b/plugins/rule-flood-guard/go.sum new file mode 100644 index 000000000..319834feb --- /dev/null +++ b/plugins/rule-flood-guard/go.sum @@ -0,0 +1,160 @@ +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= +github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= +github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= +github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM= +github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/opensearch-project/opensearch-go/v4 v4.6.0 h1:Ac8aLtDSmLEyOmv0r1qhQLw3b4vcUhE42NE9k+Z4cRc= +github.com/opensearch-project/opensearch-go/v4 v4.6.0/go.mod h1:3iZtb4SNt3IzaxavKq0dURh1AmtVgYW71E4XqmYnIiQ= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= +github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/threatwinds/go-sdk v1.1.26 h1:9anBTRXXnNfft9FDgdasMOMUxtqlzE1Cm2b81lndFQQ= +github.com/threatwinds/go-sdk v1.1.26/go.mod h1:aN6Oe3zJop9ngS83oZcKFXDLKWzrny2XhkYm7uoyDbQ= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ= +github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM= +go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8= +go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU= +golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q= +golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/plugins/rule-flood-guard/guard.go b/plugins/rule-flood-guard/guard.go new file mode 100644 index 000000000..dddd24c2f --- /dev/null +++ b/plugins/rule-flood-guard/guard.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "time" + + "github.com/threatwinds/go-sdk/catcher" +) + +type searchFunc func(ctx context.Context, window time.Duration) ([]ruleBucket, error) + +type disableNotifier interface { + Deactivate(ctx context.Context, ruleName string) (bool, error) + Notify(ctx context.Context, message string) error +} + +// getConfig is satisfied by (*configHolder).Get — kept as its own function +// type so guard.go doesn't need to know about configHolder directly. +type getConfig func() Config + +func evaluateOnce(ctx context.Context, search searchFunc, client disableNotifier, getCfg getConfig) { + cfg := getCfg() + if !cfg.Enabled { + return + } + + buckets, err := search(ctx, cfg.window()) + if err != nil { + _ = catcher.Error("rule-flood-guard: failed to search alert buckets", err, nil) + return + } + + for _, b := range buckets { + if b.Count <= cfg.Threshold { + continue + } + + changed, err := client.Deactivate(ctx, b.RuleName) + if err != nil { + _ = catcher.Error("rule-flood-guard: failed to deactivate rule", err, map[string]any{ + "ruleName": b.RuleName, "count": b.Count, + }) + continue + } + if !changed { + // Already disabled (manually, or by a previous/overlapping + // cycle) — idempotent no-op, no duplicate notification. + continue + } + + msg := floodNotificationMessage(b.RuleName, cfg.Threshold) + if err := client.Notify(ctx, msg); err != nil { + _ = catcher.Error("rule-flood-guard: failed to send notification", err, map[string]any{ + "ruleName": b.RuleName, + }) + } + } +} + +// runLoop re-reads getCfg() on every cycle (both for the enabled/threshold/ +// window checks inside evaluateOnce and for the tick interval itself via +// time.Timer.Reset below), so a hot-reloaded config file — including a +// changed IntervalSeconds — takes effect without restarting the plugin. +func runLoop(ctx context.Context, search searchFunc, client disableNotifier, getCfg getConfig) { + timer := time.NewTimer(getCfg().tickInterval()) + defer timer.Stop() + + evaluateOnce(ctx, search, client, getCfg) + + for { + select { + case <-timer.C: + evaluateOnce(ctx, search, client, getCfg) + timer.Reset(getCfg().tickInterval()) + case <-ctx.Done(): + return + } + } +} diff --git a/plugins/rule-flood-guard/main.go b/plugins/rule-flood-guard/main.go new file mode 100644 index 000000000..66e572c32 --- /dev/null +++ b/plugins/rule-flood-guard/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + "os" + "time" + + "github.com/threatwinds/go-sdk/catcher" + sdkos "github.com/threatwinds/go-sdk/os" + "github.com/threatwinds/go-sdk/plugins" + "google.golang.org/protobuf/types/known/emptypb" +) + +const pluginID = "com.utmstack.rule-flood-guard" + +func main() { + initialCfg, configPath := loadConfig() + + osCfg := plugins.PluginCfg("org.opensearch").Get("opensearch") + host := osCfg.Get("host").String() + port := osCfg.Get("port").String() + user := osCfg.Get("user").String() + password := osCfg.Get("password").String() + osURL := "https://" + host + ":" + port + + if err := sdkos.Connect([]string{osURL}, user, password); err != nil { + _ = catcher.Error("rule-flood-guard: failed connecting to OpenSearch", err, map[string]any{"process": "plugin_" + pluginID}) + time.Sleep(5 * time.Second) + os.Exit(1) + } + + client := newBackendClient(initialCfg.BackendURL, initialCfg.InternalKey) + holder := newConfigHolder(initialCfg) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go watchConfigFile(ctx, holder, configPath) + go runLoop(ctx, searchRuleBuckets, client, holder.Get) + + if err := plugins.InitNotificationPlugin(pluginID, notify); err != nil { + _ = catcher.Error("rule-flood-guard: failed to start notification plugin", err, map[string]any{"process": "plugin_" + pluginID}) + time.Sleep(5 * time.Second) + os.Exit(1) + } +} + +func notify(_ context.Context, _ *plugins.Message) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +}