Rule files, and bans for a clear sign of attack (closes #24)
check / check (push) Successful in 3m23s

Every *.rules file in SWWAF_RULES_DIR is read at start and again when
one changes, and each request is checked against the rules after the
rate limits: log notes a match, block refuses with 403, ban refuses and
bans the netblock for SWWAF_ATTACK_BAN_DURATION, made permanent by its
next request or its next clear sign of attack. bans.json gains each
ban's cause, the request log rule_ids and rule_blocked, the metrics
rule matches and rules loaded. The image ships 00-default.rules.

Judgement call: a header sent twice is matched with its values joined
by ", ".
Judgement call: SWWAF_MAX_BAN_DURATION does not cap a ban for an attack.
Not in this unit: offences for rule matches, with the error burst.

Model: opus-5-5
This commit is contained in:
2026-10-06 13:32:31 +00:00
parent 6ec52e5b87
commit c58daa4c29
26 changed files with 2045 additions and 238 deletions
+25 -3
View File
@@ -40,6 +40,7 @@ const (
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
maxBans = "SWWAF_MAX_BANS"
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
stateDir = "SWWAF_STATE_DIR"
@@ -47,6 +48,8 @@ const (
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
metricsTopN = "SWWAF_METRICS_TOP_N"
rulesDir = "SWWAF_RULES_DIR"
rulesEnabled = "SWWAF_RULES_ENABLED"
)
// token is a token of 32 characters, the shortest allowed.
@@ -100,6 +103,7 @@ func TestDefaults(t *testing.T) {
LimitBanDuration: time.Hour,
LimitBanRepeatWindow: 24 * time.Hour,
MaxBanDuration: 7 * 24 * time.Hour,
AttackBanDuration: 7 * 24 * time.Hour,
MaxBans: 5000,
BanScopeV4Prefix: 32,
StateDir: "/var/lib/smallwebwaf",
@@ -107,6 +111,8 @@ func TestDefaults(t *testing.T) {
StateCounterInterval: 15 * time.Minute,
MetricsToken: "",
MetricsTopN: 50,
RulesDir: "/etc/smallwebwaf/rules.d",
RulesEnabled: true,
})
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
@@ -150,6 +156,7 @@ func TestValuesAsSet(t *testing.T) {
limitBanDuration: "15m",
limitBanRepeatWindow: "2d",
maxBanDuration: "30d",
attackBanDuration: "1d",
maxBans: "100",
banScopeV4Prefix: "24",
stateDir: "/srv/waf-state",
@@ -157,6 +164,8 @@ func TestValuesAsSet(t *testing.T) {
stateCounterInterval: "1h",
metricsToken: token,
metricsTopN: "10",
rulesDir: "/srv/waf-rules",
rulesEnabled: "false",
})
wantSettings(t, cfg, config.Config{
@@ -177,6 +186,7 @@ func TestValuesAsSet(t *testing.T) {
LimitBanDuration: 15 * time.Minute,
LimitBanRepeatWindow: 48 * time.Hour,
MaxBanDuration: 30 * 24 * time.Hour,
AttackBanDuration: 24 * time.Hour,
MaxBans: 100,
BanScopeV4Prefix: 24,
StateDir: "/srv/waf-state",
@@ -184,6 +194,8 @@ func TestValuesAsSet(t *testing.T) {
StateCounterInterval: time.Hour,
MetricsToken: token,
MetricsTopN: 10,
RulesDir: "/srv/waf-rules",
RulesEnabled: false,
})
if cfg.UpstreamURL.String() != "https://app.internal:8443/" {
@@ -354,13 +366,14 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
{banResponse, "404"}, {banResponse, "drop"}, {banResponse, ""},
{limitBanDuration, off}, {limitBanDuration, "0s"}, {limitBanDuration, "1"},
{limitBanRepeatWindow, off}, {limitBanRepeatWindow, "-1h"},
{maxBanDuration, off}, {maxBanDuration, "1w"},
{maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off},
{maxBans, off}, {maxBans, "0"}, {maxBans, "5K"},
{banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"},
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/smallwebwaf"},
{stateWriteDelay, off}, {stateWriteDelay, "0s"},
{stateCounterInterval, off}, {stateCounterInterval, "15"},
{metricsTopN, off}, {metricsTopN, "0"}, {metricsTopN, "-1"},
{rulesEnabled, "yes"}, {rulesEnabled, "True"},
} {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
@@ -453,6 +466,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
limitBanDuration: "1h",
limitBanRepeatWindow: "24h",
maxBanDuration: "7d",
attackBanDuration: "7d",
maxBans: "5000",
banScopeV4Prefix: "32",
stateDir: "/var/lib/smallwebwaf",
@@ -460,6 +474,8 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
stateCounterInterval: "15m",
metricsToken: "",
metricsTopN: "50",
rulesDir: "/etc/smallwebwaf/rules.d",
rulesEnabled: "true",
}
if !maps.Equal(line.Settings, want) {
t.Errorf("logged settings\n%v\nwant\n%v", line.Settings, want)
@@ -489,8 +505,8 @@ func wantSettings(t *testing.T, got *config.Config, want config.Config) {
wantBanSettings(t, got, want)
}
// wantBanSettings checks the settings for bans, the state files and the
// metrics.
// wantBanSettings checks the settings for bans, the state files, the
// metrics and the rule files.
func wantBanSettings(t *testing.T, got *config.Config, want config.Config) {
t.Helper()
@@ -498,11 +514,17 @@ func wantBanSettings(t *testing.T, got *config.Config, want config.Config) {
got.LimitBanDuration != want.LimitBanDuration ||
got.LimitBanRepeatWindow != want.LimitBanRepeatWindow ||
got.MaxBanDuration != want.MaxBanDuration ||
got.AttackBanDuration != want.AttackBanDuration ||
got.MaxBans != want.MaxBans ||
got.BanScopeV4Prefix != want.BanScopeV4Prefix {
t.Errorf("ban settings\n%+v\nwant\n%+v", got, want)
}
if got.RulesDir != want.RulesDir || got.RulesEnabled != want.RulesEnabled {
t.Errorf("rule files in %q, on: %t, want %q, %t",
got.RulesDir, got.RulesEnabled, want.RulesDir, want.RulesEnabled)
}
if got.StateDir != want.StateDir ||
got.StateWriteDelay != want.StateWriteDelay ||
got.StateCounterInterval != want.StateCounterInterval {