AbuseIPDB scores for clients that broke a limit, within a daily budget (closes #105)
check / check (push) Waiting to run

With SWWAF_ABUSEIPDB_KEY set, a client whose history counts an offence
is checked in the background, at most SWWAF_ABUSEIPDB_DAILY_BUDGET checks
a day, the count kept in reputation.json. A score at or over
SWWAF_ABUSEIPDB_MIN_SCORE is a hit for SWWAF_REPUTATION_ACTION, logged as
abuseipdb and alerted with its score. A failure or the used-up budget
gives no score and raises source_failure. The key goes only in the Key
header.

Judgement call: the budget's day is UTC; AbuseIPDB documents no reset time.
Judgement call: each check sent spends budget; a minute's pause after a failure.
Judgement call: offences are those the history counts, so far broken limits only.

Model: opus-5-5
This commit is contained in:
2026-10-07 19:53:17 +00:00
parent 0b0f207423
commit 4bc8d9edb4
21 changed files with 1862 additions and 304 deletions
+76
View File
@@ -63,6 +63,9 @@ const (
blocklistAction = "SWWAF_BLOCKLIST_ACTION"
dnsblZones = "SWWAF_DNSBL_ZONES"
dnsblResolver = "SWWAF_DNSBL_RESOLVER"
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY"
abuseIPDBMinScore = "SWWAF_ABUSEIPDB_MIN_SCORE"
abuseIPDBDailyBudget = "SWWAF_ABUSEIPDB_DAILY_BUDGET"
reputationAction = "SWWAF_REPUTATION_ACTION"
reputationCacheTTL = "SWWAF_REPUTATION_CACHE_TTL"
reputationTimeout = "SWWAF_REPUTATION_TIMEOUT"
@@ -1526,6 +1529,76 @@ func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) {
}
}
func TestAbuseIPDBSettingsAsSet(t *testing.T) {
t.Parallel()
cfg := fromEnvironment(t, environment{})
if cfg.AbuseIPDBKey != "" || cfg.AbuseIPDBMinScore != 75 ||
cfg.AbuseIPDBDailyBudget != 900 {
t.Errorf("by default, the key %q, the minimum score %d and the daily budget %d, "+
"want none, 75 and 900", cfg.AbuseIPDBKey, cfg.AbuseIPDBMinScore,
cfg.AbuseIPDBDailyBudget)
}
cfg = fromEnvironment(t, environment{
abuseIPDBKey: token, abuseIPDBMinScore: "0", abuseIPDBDailyBudget: "1",
})
if cfg.AbuseIPDBKey != token || cfg.AbuseIPDBMinScore != 0 ||
cfg.AbuseIPDBDailyBudget != 1 {
t.Errorf("set, the key %q, the minimum score %d and the daily budget %d, "+
"want %s, 0 and 1", cfg.AbuseIPDBKey, cfg.AbuseIPDBMinScore,
cfg.AbuseIPDBDailyBudget, token)
}
}
func TestInvalidAbuseIPDBSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Parallel()
const (
notScore = " is not a percentage, a whole number from 0 to 100"
notBudget = " is not a whole number above zero, such as 5000"
)
for _, tc := range []struct{ name, value, want string }{
{abuseIPDBMinScore, "101", `"101"` + notScore},
{abuseIPDBMinScore, off, `"off"` + notScore},
{abuseIPDBDailyBudget, "0", `"0"` + notBudget},
{abuseIPDBDailyBudget, off, `"off"` + notBudget},
// The key itself is never shown.
{
abuseIPDBKey, token + "\r",
"holds a control character, such as the carriage return of a Windows line end",
},
} {
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 TestAbuseIPDBKeyIsLoggedMasked(t *testing.T) {
t.Parallel()
cfg := fromEnvironment(t, environment{abuseIPDBKey: token})
var out bytes.Buffer
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
logged := out.String()
if strings.Contains(logged, token) ||
!strings.Contains(logged, `"`+abuseIPDBKey+`":"********"`) {
t.Errorf("the key is not logged masked: %s", logged)
}
}
func TestSizesAndOff(t *testing.T) {
t.Parallel()
@@ -1956,6 +2029,9 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
blocklistAction: actionDeny,
dnsblZones: "",
dnsblResolver: "",
abuseIPDBKey: "",
abuseIPDBMinScore: "75",
abuseIPDBDailyBudget: "900",
reputationAction: "limit:25",
reputationCacheTTL: defaultReputationCacheTTL,
reputationTimeout: "2s",