Anomaly thresholds: alerts for unusual traffic, nothing refused (closes #101)
check / check (push) Waiting to run
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:
+211
-11
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user