check / check (push) Waiting to run
SWWAF_ANOMALY_CLIENT_*, _NET_*, _ASN_*, _TOTAL_* and SWWAF_WATCH_* with SWWAF_WATCH_NETS: requests and bytes per minute and per hour, each off by default; with all off, nothing is counted. Otherwise every request but the health check is counted, allow-listed and exempt ones included; a count over its threshold raises an anomaly alert, with a cooldown per scope. At most 20,000 counters, kept in alerts.json. A per-AS-number threshold with lookups off, or a malformed SWWAF_WATCH_NETS, stops the start. A cooldown that has run out is dropped as the hour ends, whatever it held back; the hour's summary gives its repeats. Judgement call: refused requests are counted too. Judgement call: per-client counters are kept in alerts.json, which SPEC.md does not list. Judgement call: a request counts for an AS number only if the lookup answered before it ended. Model: opus-5-5
239 lines
7.1 KiB
Go
239 lines
7.1 KiB
Go
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
|
|
})
|
|
}
|