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
+115 -5
View File
@@ -24,6 +24,7 @@ import (
"unicode/utf8"
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/remotelog"
)
@@ -220,6 +221,23 @@ type Config struct {
AlertEvents []string
AlertCooldown time.Duration
AlertMaxPerHour int
// The anomaly thresholds, which only raise alerts: the most requests
// and bytes a minute and an hour per client (SWWAF_ANOMALY_CLIENT_*),
// per netblock around a client (SWWAF_ANOMALY_NET_*), per AS number
// (SWWAF_ANOMALY_ASN_*), for the whole service (SWWAF_ANOMALY_TOTAL_*)
// and per named netblock (SWWAF_WATCH_*), each 0 while it is off.
// AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths of the
// netblock around a client (SWWAF_ANOMALY_NET_V4_PREFIX and
// SWWAF_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks
// (SWWAF_WATCH_NETS).
AnomalyClient anomaly.Thresholds
AnomalyNet anomaly.Thresholds
AnomalyASN anomaly.Thresholds
AnomalyTotal anomaly.Thresholds
AnomalyWatch anomaly.Thresholds
AnomalyNetV4Prefix int
AnomalyNetV6Prefix int
WatchNets []anomaly.NamedNetblock
// settings are the values read, as given or by default, and the
// files they were read from, for the log line at start.
@@ -240,6 +258,7 @@ const (
mebibyte = 1 << 20
gibibyte = 1 << 30
ipv4Bits = 32
ipv6Bits = 128
// minTokenLength is the fewest characters a token may have.
minTokenLength = 32
// masked is what the log shows for a token that is set, and in place of
@@ -287,6 +306,10 @@ var (
errNotBanResponse = errors.New("is not 403, 429 or close")
errNotV4Prefix = errors.New(
"is not the length of an IPv4 netblock, from 0 to 32, such as 24")
errNotV6Prefix = errors.New(
"is not the length of an IPv6 netblock, from 0 to 128, such as 48")
errNotNamedNetblock = errors.New(
"is not a name, = and a netblock, such as office=203.0.113.0/24")
errNotAbsolutePath = errors.New(
"is not an absolute path, such as /var/lib/smallwebwaf")
errShortToken = errors.New("is shorter than 32 characters")
@@ -398,8 +421,16 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
AlertNtfyToken: env.secret("SWWAF_ALERT_NTFY_TOKEN"),
AlertEvents: env.alertEvents("SWWAF_ALERT_EVENTS",
strings.Join(alerts.Events(), ",")),
AlertCooldown: env.duration("SWWAF_ALERT_COOLDOWN", "15m"),
AlertMaxPerHour: env.numberOrOff("SWWAF_ALERT_MAX_PER_HOUR", "60"),
AlertCooldown: env.duration("SWWAF_ALERT_COOLDOWN", "15m"),
AlertMaxPerHour: env.numberOrOff("SWWAF_ALERT_MAX_PER_HOUR", "60"),
AnomalyClient: env.thresholds("SWWAF_ANOMALY_CLIENT_"),
AnomalyNet: env.thresholds("SWWAF_ANOMALY_NET_"),
AnomalyASN: env.thresholds("SWWAF_ANOMALY_ASN_"),
AnomalyTotal: env.thresholds("SWWAF_ANOMALY_TOTAL_"),
AnomalyWatch: env.thresholds("SWWAF_WATCH_"),
AnomalyNetV4Prefix: env.v4Prefix("SWWAF_ANOMALY_NET_V4_PREFIX", "24"),
AnomalyNetV6Prefix: env.v6Prefix("SWWAF_ANOMALY_NET_V6_PREFIX", "48"),
WatchNets: env.namedNetblocks("SWWAF_WATCH_NETS"),
}
cfg.LogRemoteAppName = env.appName("SWWAF_LOG_REMOTE_APP_NAME",
@@ -666,9 +697,9 @@ func (e *environment) checkLookupDBPath(cfg *Config) {
// checkCountriesAndLookups refuses a country on both country lists, and,
// while SWWAF_LOOKUP_SOURCE is off, each setting that needs clients looked
// up: the country lists, SWWAF_ADD_LOOKUP_HEADERS, and the biased
// thresholds, of which SWWAF_UNKNOWN_LIMIT_PERCENT needs them only below
// 100, where it lowers a limit.
// up: the country lists, SWWAF_ADD_LOOKUP_HEADERS, the biased thresholds,
// of which SWWAF_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it
// lowers a limit, and the anomaly thresholds per AS number.
func (e *environment) checkCountriesAndLookups(cfg *Config) {
for _, country := range cfg.ExclusivelyAllowedCountries {
if slices.Contains(cfg.DeniedCountries, country) {
@@ -693,6 +724,10 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
{"SWWAF_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
{"SWWAF_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
{"SWWAF_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
{"SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0},
{"SWWAF_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0},
{"SWWAF_ANOMALY_ASN_BYTES_PER_MINUTE", cfg.AnomalyASN.BytesPerMinute > 0},
{"SWWAF_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0},
} {
if setting.set {
e.check(setting.name, fmt.Errorf("is set while SWWAF_LOOKUP_SOURCE is off; %w",
@@ -744,6 +779,35 @@ func (e *environment) v4Prefix(name, defaultValue string) int {
return length
}
// v6Prefix reads a setting that is the length of an IPv6 netblock.
func (e *environment) v6Prefix(name, defaultValue string) int {
length, err := parseV6Prefix(e.value(name, defaultValue))
e.check(name, err)
return length
}
// thresholds reads the four anomaly thresholds whose settings' names
// start with prefix: requests and bytes per minute and per hour. Each is
// off by default.
func (e *environment) thresholds(prefix string) anomaly.Thresholds {
return anomaly.Thresholds{
RequestsPerMinute: e.count(prefix+"REQUESTS_PER_MINUTE", off),
RequestsPerHour: e.count(prefix+"REQUESTS_PER_HOUR", off),
BytesPerMinute: e.size(prefix+"BYTES_PER_MINUTE", off),
BytesPerHour: e.size(prefix+"BYTES_PER_HOUR", off),
}
}
// namedNetblocks reads a setting that is a list of named netblocks. It is
// empty by default.
func (e *environment) namedNetblocks(name string) []anomaly.NamedNetblock {
named, err := parseNamedNetblocks(e.value(name, ""))
e.check(name, err)
return named
}
// absolutePath reads a setting that is an absolute path.
func (e *environment) absolutePath(name, defaultValue string) string {
path := e.value(name, defaultValue)
@@ -1085,6 +1149,16 @@ func parseV4Prefix(value string) (int, error) {
return n, nil
}
// parseV6Prefix reads the length of an IPv6 netblock, from 0 to 128.
func parseV6Prefix(value string) (int, error) {
n, err := strconv.Atoi(value)
if err != nil || n < 0 || n > ipv6Bits {
return 0, fmt.Errorf("%q %w", value, errNotV6Prefix)
}
return n, nil
}
// parseList splits a comma-separated list and trims the spaces around
// each item. An empty value is an empty list.
func parseList(value string) ([]string, error) {
@@ -1144,6 +1218,42 @@ func parseNetblock(value string) (netip.Prefix, error) {
return netip.PrefixFrom(addr, addr.BitLen()), nil
}
// parseNamedNetblocks reads a comma-separated list of named netblocks,
// each a name, = and a netblock, such as office=203.0.113.0/24. An empty
// value is an empty list. A name listed twice is an error.
func parseNamedNetblocks(value string) ([]anomaly.NamedNetblock, error) {
items, err := parseList(value)
if err != nil {
return nil, err
}
named := make([]anomaly.NamedNetblock, 0, len(items))
for _, item := range items {
name, netblockText, found := strings.Cut(item, "=")
name = strings.TrimSpace(name)
if !found || name == "" {
return nil, fmt.Errorf("%q %w", item, errNotNamedNetblock)
}
netblock, err := parseNetblock(strings.TrimSpace(netblockText))
if err != nil {
return nil, err
}
if slices.ContainsFunc(named, func(n anomaly.NamedNetblock) bool {
return n.Name == name
}) {
return nil, fmt.Errorf("%q %w", name, errListedTwice)
}
named = append(named, anomaly.NamedNetblock{Name: name, Netblock: netblock})
}
return named, nil
}
// parsePathPrefixes reads a comma-separated list of path prefixes, each
// starting with /.
func parsePathPrefixes(value string) ([]string, error) {