Anomaly thresholds: alerts for unusual traffic, nothing refused (closes #101)
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
This commit is contained in:
2026-10-07 13:41:06 +00:00
parent 2421cdc273
commit bd23e35579
17 changed files with 2121 additions and 288 deletions
+211 -11
View File
@@ -12,10 +12,12 @@ import (
"path/filepath"
"reflect"
"slices"
"strconv"
"strings"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/config"
)
@@ -85,8 +87,59 @@ const (
alertEvents = "SWWAF_ALERT_EVENTS"
alertCooldown = "SWWAF_ALERT_COOLDOWN"
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR"
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX"
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX"
watchNets = "SWWAF_WATCH_NETS"
)
// The anomaly thresholds: each of the prefixes below, which name a scope,
// followed by each of the four ends.
const (
anomalyClient = "SWWAF_ANOMALY_CLIENT_"
anomalyNet = "SWWAF_ANOMALY_NET_"
anomalyASN = "SWWAF_ANOMALY_ASN_"
anomalyTotal = "SWWAF_ANOMALY_TOTAL_"
watch = "SWWAF_WATCH_"
requestsPerMinute = "REQUESTS_PER_MINUTE"
requestsPerHour = "REQUESTS_PER_HOUR"
bytesPerMinute = "BYTES_PER_MINUTE"
bytesPerHour = "BYTES_PER_HOUR"
)
// anomalyScopes returns the prefixes of the anomaly thresholds, one for
// each scope.
func anomalyScopes() []string {
return []string{anomalyClient, anomalyNet, anomalyASN, anomalyTotal, watch}
}
// anomalyThresholds returns the names of the twenty anomaly thresholds.
func anomalyThresholds() []string {
ends := []string{requestsPerMinute, requestsPerHour, bytesPerMinute, bytesPerHour}
names := make([]string, 0, len(anomalyScopes())*len(ends))
for _, scope := range anomalyScopes() {
for _, end := range ends {
names = append(names, scope+end)
}
}
return names
}
// loggedAnomalyDefaults returns the anomaly settings as the settings
// logged at start give them by default.
func loggedAnomalyDefaults() map[string]string {
logged := map[string]string{
anomalyNetV4Prefix: "24", anomalyNetV6Prefix: "48", watchNets: "",
}
for _, name := range anomalyThresholds() {
logged[name] = off
}
return logged
}
// defaultAlertEvents is the default of SWWAF_ALERT_EVENTS, and
// defaultAlertCooldown that of SWWAF_ALERT_COOLDOWN.
const (
@@ -897,14 +950,18 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
t.Parallel()
for name, value := range map[string]string{
deniedCountries: "kp",
allowedCountries: "de",
addLookupHeaders: enabled,
asnLimitPercent: "AS64496:50",
countryLimitPercent: "cn:25",
asnBytesPercent: "AS64496:50",
countryBytesPercent: "cn:25",
unknownLimitPercent: "99",
deniedCountries: "kp",
allowedCountries: "de",
addLookupHeaders: enabled,
asnLimitPercent: "AS64496:50",
countryLimitPercent: "cn:25",
asnBytesPercent: "AS64496:50",
countryBytesPercent: "cn:25",
unknownLimitPercent: "99",
anomalyASN + requestsPerMinute: "1000",
anomalyASN + requestsPerHour: "10000",
anomalyASN + bytesPerMinute: "1G",
anomalyASN + bytesPerHour: "10G",
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
@@ -920,12 +977,153 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
}
// Set empty, the lists need nothing looked up, and nor does
// SWWAF_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit.
fromEnvironment(t, environment{
// SWWAF_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly
// threshold per AS number that is off, or any other anomaly threshold.
env := environment{
lookupSource: off, deniedCountries: "", allowedCountries: "",
asnLimitPercent: "", countryLimitPercent: "", asnBytesPercent: "",
countryBytesPercent: "", unknownLimitPercent: "100",
})
}
for _, name := range anomalyThresholds() {
env[name] = "1000"
if strings.HasPrefix(name, anomalyASN) {
env[name] = off
}
}
fromEnvironment(t, env)
}
func TestAnomalySettingsDefaults(t *testing.T) {
t.Parallel()
cfg := fromEnvironment(t, environment{})
wantAllOff(t, cfg)
if cfg.AnomalyNetV4Prefix != 24 || cfg.AnomalyNetV6Prefix != 48 ||
len(cfg.WatchNets) != 0 {
t.Errorf("%s, %s and %s gave %d, %d and %v, want 24, 48 and none",
anomalyNetV4Prefix, anomalyNetV6Prefix, watchNets, cfg.AnomalyNetV4Prefix,
cfg.AnomalyNetV6Prefix, cfg.WatchNets)
}
}
func TestAnomalySettingsAsSet(t *testing.T) {
t.Parallel()
// Each threshold of a scope its own value; bytes are sizes.
env := environment{
anomalyNetV4Prefix: "16",
anomalyNetV6Prefix: "56",
// Spaces around a name or a netblock, and a bare address.
watchNets: "office = 203.0.113.0/24, scraper-x=198.51.100.7,v6=2001:db8::/32",
}
want := map[string]anomaly.Thresholds{}
for i, scope := range anomalyScopes() {
n := int64(i + 1)
env[scope+requestsPerMinute] = strconv.FormatInt(n, 10)
env[scope+requestsPerHour] = strconv.FormatInt(10*n, 10)
env[scope+bytesPerMinute] = strconv.FormatInt(n, 10) + "K"
env[scope+bytesPerHour] = strconv.FormatInt(n, 10) + "G"
want[scope] = anomaly.Thresholds{
RequestsPerMinute: n, RequestsPerHour: 10 * n,
BytesPerMinute: n << 10, BytesPerHour: n << 30,
}
}
cfg := fromEnvironment(t, env)
if got := thresholdsByScope(cfg); !maps.Equal(got, want) {
t.Errorf("thresholds by scope\n%+v\nwant\n%+v", got, want)
}
wantNamed := []anomaly.NamedNetblock{
{Name: "office", Netblock: netip.MustParsePrefix("203.0.113.0/24")},
{Name: "scraper-x", Netblock: netip.MustParsePrefix("198.51.100.7/32")},
{Name: "v6", Netblock: netip.MustParsePrefix("2001:db8::/32")},
}
if cfg.AnomalyNetV4Prefix != 16 || cfg.AnomalyNetV6Prefix != 56 ||
!slices.Equal(cfg.WatchNets, wantNamed) {
t.Errorf("%s, %s and %s gave %d, %d and %v, want 16, 56 and %v",
anomalyNetV4Prefix, anomalyNetV6Prefix, watchNets, cfg.AnomalyNetV4Prefix,
cfg.AnomalyNetV6Prefix, cfg.WatchNets, wantNamed)
}
// off switches each threshold off.
for _, name := range anomalyThresholds() {
env[name] = off
}
wantAllOff(t, fromEnvironment(t, env))
}
// thresholdsByScope returns cfg's anomaly thresholds, each by the prefix
// of its scope's settings.
func thresholdsByScope(cfg *config.Config) map[string]anomaly.Thresholds {
return map[string]anomaly.Thresholds{
anomalyClient: cfg.AnomalyClient, anomalyNet: cfg.AnomalyNet,
anomalyASN: cfg.AnomalyASN, anomalyTotal: cfg.AnomalyTotal,
watch: cfg.AnomalyWatch,
}
}
// wantAllOff checks that every anomaly threshold of cfg is off.
func wantAllOff(t *testing.T, cfg *config.Config) {
t.Helper()
for scope, thresholds := range thresholdsByScope(cfg) {
if thresholds != (anomaly.Thresholds{}) {
t.Errorf("%s* gave %+v, want every one off", scope, thresholds)
}
}
}
func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Parallel()
const (
notCount = " is not a whole number of requests such as 1000, or off"
notSize = " is not a size such as 512K, 100M or 5G, or off"
notPositive = " must be more than zero, or off"
notNamed = " is not a name, = and a netblock, such as office=203.0.113.0/24"
notNetblock = " is not a netblock such as 10.0.0.0/8, or an address"
notV4Prefix = " is not the length of an IPv4 netblock, from 0 to 32, such as 24"
notV6Prefix = " is not the length of an IPv6 netblock, from 0 to 128, such as 48"
officeNetblock = "office=203.0.113.0/24"
scraperNetblock = "scraper=198.51.100.0/24"
)
for _, tc := range []struct{ name, value, want string }{
{anomalyClient + requestsPerMinute, "1K", `"1K"` + notCount},
{anomalyNet + requestsPerHour, "0", `"0"` + notPositive},
{anomalyTotal + bytesPerMinute, "1T", `"1T"` + notSize},
{watch + bytesPerHour, "-1G", `"-1G"` + notPositive},
{anomalyNetV4Prefix, "33", `"33"` + notV4Prefix},
{anomalyNetV4Prefix, off, `"off"` + notV4Prefix},
{anomalyNetV6Prefix, "129", `"129"` + notV6Prefix},
{anomalyNetV6Prefix, "/48", `"/48"` + notV6Prefix},
{watchNets, "office", `"office"` + notNamed},
{watchNets, "=203.0.113.0/24", `"=203.0.113.0/24"` + notNamed},
{watchNets, "office=203.0.113.300/24", `"203.0.113.300/24"` + notNetblock},
{watchNets, officeNetblock + ",", `"` + officeNetblock + `," has an empty item ` +
`in its list`},
{
watchNets, officeNetblock + "," + scraperNetblock + ",office=192.0.2.0/24",
`"office" is listed twice`,
},
} {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
want := tc.name + ": " + tc.want
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
})
}
}
func TestBiasedThresholdsAsSet(t *testing.T) {
@@ -1461,6 +1659,8 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
alertCooldown: defaultAlertCooldown,
alertMaxPerHour: "60",
}
maps.Copy(want, loggedAnomalyDefaults())
if got := loggedSettings(t, cfg); !maps.Equal(got, want) {
t.Errorf("logged settings\n%v\nwant\n%v", got, want)
}