package anomaly_test import ( "encoding/json" "fmt" "net/netip" "net/url" "reflect" "slices" "testing" "time" "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/anomaly" "sneak.berlin/go/smallwebwaf/internal/ratelimit" ) // maxCounters is how many counters are kept. const maxCounters = 20000 func TestEachScopeHasACooldownOfItsOwn(t *testing.T) { t.Parallel() queue := newQueue() office := netip.MustParsePrefix("203.0.113.0/24") overAtTheSecond := anomaly.Thresholds{RequestsPerMinute: 1} counters := anomaly.New(anomaly.Params{ Client: overAtTheSecond, Net: overAtTheSecond, ASN: overAtTheSecond, Total: overAtTheSecond, Watch: overAtTheSecond, // The netblock around a client is the client's own, and two names // name one netblock. NetV4Prefix: 32, NamedNetblocks: []anomaly.NamedNetblock{ {Name: "office", Netblock: office}, {Name: "hq", Netblock: office}, }, Alerts: queue, }) // The first client's second request is over the threshold in the six // scopes it is in. The other client's two are both over it in the whole // service and in each named netblock, three repeats each, and its // second is over it in the scopes of its own, its client, its netblock // and its AS number, which are no repeats. for _, r := range []anomaly.Request{ {Client: netip.MustParseAddr("203.0.113.9"), ASN: "AS64496"}, {Client: netip.MustParseAddr("203.0.113.10"), ASN: "AS64511"}, } { r.ClientGroup = netip.PrefixFrom(r.Client, 32) for range 2 { counters.Count(midnight(), r) } } waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] if len(waiting) != 9 || queue.Suppressed() != 6 { t.Fatalf("%d alerts wait and %d are held back, want 9 and 6: %+v", len(waiting), queue.Suppressed(), waiting) } // alerts.json keeps each scope's cooldown: each alert raised again // after a restart is a repeat. data, err := json.Marshal(queue.Snapshot()) if err != nil { t.Fatalf("encode: %v", err) } var read alerts.State err = json.Unmarshal(data, &read) if err != nil { t.Fatalf("decode: %v", err) } after := newQueue() after.Load(read) for _, alert := range read.Waiting[alerts.DestinationWebhook] { after.Raise(alert) } if after.Suppressed() != 9 { t.Errorf("after loading, %d alerts are held back, want 9", after.Suppressed()) } } func TestKeepsAtMost20000CountersDroppingTheLeastRecentlyCounted(t *testing.T) { t.Parallel() counters := newCounters(anomaly.Params{ Client: anomaly.Thresholds{RequestsPerMinute: 1000}, }) for i := range maxCounters { counters.Count(midnight(), request(i)) } // Counted again, the first client is the most recently counted, and // the second is dropped for a new one. counters.Count(midnight(), request(0)) counters.Count(midnight(), request(maxCounters)) got := counters.Snapshot() if len(got) != maxCounters || !holds(got, 0) || holds(got, 1) || !holds(got, maxCounters) { t.Errorf("%d counters, holding the first client %v, the second %v and the "+ "new one %v, want %d, the first and the new one", len(got), holds(got, 0), holds(got, 1), holds(got, maxCounters), maxCounters) } } func TestLoadEmptiesBucketsWhoseTimeHasPassedAndDropsEmptyCounters(t *testing.T) { t.Parallel() counters := newCounters(anomaly.Params{ Net: anomaly.Thresholds{RequestsPerMinute: 1000, RequestsPerHour: 1000}, Total: anomaly.Thresholds{RequestsPerMinute: 1000}, NetV4Prefix: 24, }) halfAnHourOn := midnight().Add(30 * time.Minute) // Half an hour on, the hour's buckets count still, and the minute's // do not. counters.Load([]anomaly.Counter{ { Scope: anomaly.ScopeNet, Netblock: netip.MustParsePrefix("203.0.113.9/24"), Minute: ratelimit.Buckets{Start: midnight(), Current: 5}, Hour: ratelimit.Buckets{Start: midnight(), Current: 7}, }, { Scope: anomaly.ScopeTotal, Minute: ratelimit.Buckets{Start: midnight(), Current: 1}, }, }, halfAnHourOn) // The whole service's counter, left empty, is dropped, and the // netblock read is masked to its length. netblock := anomaly.Counter{ Scope: anomaly.ScopeNet, Netblock: netip.MustParsePrefix("203.0.113.0/24"), Hour: ratelimit.Buckets{Start: midnight(), Current: 7}, } if got, want := counters.Snapshot(), []anomaly.Counter{netblock}; !reflect.DeepEqual( got, want) { t.Errorf("counters read\n%+v\nwant\n%+v", got, want) } // A request from the netblock is counted with the requests read. counters.Count(halfAnHourOn, anomaly.Request{ Client: netip.MustParseAddr("203.0.113.9"), ClientGroup: netip.MustParsePrefix("203.0.113.9/32"), }) netblock.Minute = ratelimit.Buckets{Start: halfAnHourOn, Current: 1} netblock.Hour.Current = 8 want := []anomaly.Counter{netblock, { Scope: anomaly.ScopeTotal, Minute: ratelimit.Buckets{Start: halfAnHourOn, Current: 1}, }} if got := counters.Snapshot(); !reflect.DeepEqual(got, want) { t.Errorf("counters after a request\n%+v\nwant\n%+v", got, want) } } func TestLoadDropsTheLeastRecentlyCountedFirst(t *testing.T) { t.Parallel() counters := newCounters(anomaly.Params{ Client: anomaly.Thresholds{RequestsPerMinute: 1000}, }) now := midnight().Add(time.Minute) // The second half of the file was counted in the minute before the // first half. read := make([]anomaly.Counter, 0, maxCounters) for i := range maxCounters { start := now if i >= maxCounters/2 { start = midnight() } read = append(read, anomaly.Counter{ Scope: anomaly.ScopeClient, Netblock: request(i).ClientGroup, Minute: ratelimit.Buckets{Start: start, Current: 1}, }) } counters.Load(read, now) counters.Count(now, request(maxCounters)) got := counters.Snapshot() if !holds(got, 0) || holds(got, maxCounters/2) { t.Errorf("holding the first client of the file %v, and the first counted in "+ "the minute before %v, want only the first", holds(got, 0), holds(got, maxCounters/2)) } } // midnight is the time of the tests' requests. func midnight() time.Time { return time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) } // newCounters returns Counters for params, whose alerts go nowhere. func newCounters(params anomaly.Params) *anomaly.Counters { params.Alerts = alerts.New(alerts.Params{}) return anomaly.New(params) } // newQueue returns a queue of alerts to a webhook, with the default // cooldown, which keeps them waiting, since it is never run. func newQueue() *alerts.Queue { return alerts.New(alerts.Params{ WebhookURL: &url.URL{Scheme: "https", Host: "alerts.example"}, Events: alerts.Events(), Cooldown: 15 * time.Minute, MaxPerHour: 60, Now: midnight, }) } // request returns a request from client number i, an address in // 10.0.0.0/8. func request(i int) anomaly.Request { client := netip.MustParseAddr(fmt.Sprintf("10.%d.%d.%d", i>>16, i>>8&255, i&255)) return anomaly.Request{Client: client, ClientGroup: netip.PrefixFrom(client, 32)} } // holds reports whether counters hold the counter of client number i. func holds(counters []anomaly.Counter, i int) bool { return slices.ContainsFunc(counters, func(counter anomaly.Counter) bool { return counter.Netblock == request(i).ClientGroup }) }