Ban notes name the reputation sources that listed the client (closes #109)
check / check (push) Waiting to run

A ban's notes, in bans.json and in its alert, gain `reputation`: each
blocklist, DNSBL zone or AbuseIPDB that listed the client when the ban
was made, as its `source`, named and ordered as in the request log's
`reputation`, with AbuseIPDB's `score`. It is left out when none did.
README.md shows it in a bans.json example.

Notes now hold a list, so bans can no longer be compared with ==: the
tests compare them with reflect.DeepEqual.

Judgement call: the score is a pointer, so a score of 0, a hit while
SWWAF_ABUSEIPDB_MIN_SCORE is 0, is still written.

Model: opus-5-5
This commit is contained in:
2026-10-08 00:40:10 +00:00
parent a6634454cd
commit e3e0758465
16 changed files with 243 additions and 68 deletions
+69
View File
@@ -6,6 +6,7 @@ import (
"maps"
"net/http"
"net/netip"
"reflect"
"slices"
"strconv"
"strings"
@@ -13,6 +14,7 @@ import (
"time"
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation"
@@ -874,6 +876,73 @@ func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
`instance="`+alertInstance+`",source="`+abuseipdb+`"}`)
}
func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
t.Parallel()
score := int64(90)
listed := []bans.ReputationHit{
{Source: dropURL}, {Source: dnsblZone}, {Source: abuseipdb, Score: &score},
}
for _, tc := range []struct {
name string
// ban sends the requests from the client at from that ban it.
ban func(s *sender, from string)
}{
{"for a broken rate limit", func(s *sender, from string) {
s.get(from, http.StatusOK, requestlog.ActionForward)
s.get(from, http.StatusForbidden, requestlog.ActionRateLimited)
}},
{"for a clear sign of attack", func(s *sender, from string) {
s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned)
}},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s, _, server, queue := startWithAlerts(t, map[string]string{
rateLimitPerMinute: "1", rulesDir: writeRules(t, testRules),
blocklistURLs: dropURL, blocklistAction: actionLog,
dnsblZones: dnsblZone, dnsblResolver: noResolver,
abuseIPDBKey: accountKey, reputationAction: actionLog,
})
// Every source lists client, and none otherClient, whose score is
// under SWWAF_ABUSEIPDB_MIN_SCORE, 75 by default.
loadLists(t, server, map[string][]string{dropURL: {client}})
loadVerdicts(server, map[string][]string{client: {dnsblZone}, otherClient: nil})
loadScores(server, map[string]int64{client: score, otherClient: 74})
for _, banned := range []struct {
from string
want []bans.ReputationHit
}{{client, listed}, {otherClient, nil}} {
tc.ban(s, banned.from)
held := server.Ledger.Bans(netip.MustParsePrefix(banned.from + "/32"))
if len(held) != 1 || !reflect.DeepEqual(held[0].Notes.Reputation, banned.want) {
t.Errorf("bans of %s %+v, want one whose notes have the reputation %+v",
banned.from, held, banned.want)
}
}
// The alert for each ban carries the same in its notes.
var alerted [][]bans.ReputationHit
for _, alert := range queue.Snapshot().Waiting[alerts.DestinationWebhook] {
if alert.Event == alerts.EventBan {
notes, _ := alert.Detail["notes"].(bans.Notes)
alerted = append(alerted, notes.Reputation)
}
}
if want := [][]bans.ReputationHit{listed, nil}; !reflect.DeepEqual(alerted, want) {
t.Errorf("the ban alerts' notes have the reputation %+v, want %+v",
alerted, want)
}
})
}
}
// listsFetched is when loadLists has the copies fetched.
func listsFetched() time.Time {
return time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)