Ban notes name the reputation sources that listed the client (closes #109)
check / check (push) Waiting to run
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 was merged in pull request #114.
This commit is contained in:
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -48,7 +48,7 @@ func TestAdminEndpointsAreOffWhileTheTokenIsUnset(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if after := server.Ledger.Snapshot(); !slices.Equal(after, before) {
|
||||
if after := server.Ledger.Snapshot(); !reflect.DeepEqual(after, before) {
|
||||
t.Errorf("the bans are now\n%+v\nwant them unchanged\n%+v", after, before)
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if after := server.Ledger.Snapshot(); !slices.Equal(after, before) {
|
||||
if after := server.Ledger.Snapshot(); !reflect.DeepEqual(after, before) {
|
||||
t.Errorf("%s %s without the token changed the bans to\n%+v\nfrom\n%+v",
|
||||
e.method, e.path, after, before)
|
||||
}
|
||||
|
||||
@@ -118,7 +118,8 @@ func TestObserveModeRaisesTheBanAlertsItWouldHave(t *testing.T) {
|
||||
line := s.get(ipv6Client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
// No ban is made, and none made permanent.
|
||||
if held := server.Ledger.Snapshot(); len(held) != 1 || held[0] != attackBan ||
|
||||
held := server.Ledger.Snapshot()
|
||||
if len(held) != 1 || !reflect.DeepEqual(held[0], attackBan) ||
|
||||
line.BanExpires != requestlog.FormatTime(attackBan.Expires) {
|
||||
t.Errorf("the ledger holds %+v, and the log line gives %s, want the ban "+
|
||||
"for the attack alone, as it was", held, line.BanExpires)
|
||||
|
||||
+18
-16
@@ -127,15 +127,16 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
||||
}
|
||||
|
||||
notes := bans.Notes{
|
||||
ASN: rq.line.ASN,
|
||||
ASName: rq.line.ASName,
|
||||
Country: rq.line.Country,
|
||||
Kind: hit.Kind,
|
||||
Limit: hit.Limit,
|
||||
Window: hit.Window,
|
||||
Count: hit.Count,
|
||||
Request: rq.noted(now, status),
|
||||
Requests: rq.netblockRequests(netblock),
|
||||
ASN: rq.line.ASN,
|
||||
ASName: rq.line.ASName,
|
||||
Country: rq.line.Country,
|
||||
Kind: hit.Kind,
|
||||
Limit: hit.Limit,
|
||||
Window: hit.Window,
|
||||
Count: hit.Count,
|
||||
Reputation: rq.reputation,
|
||||
Request: rq.noted(now, status),
|
||||
Requests: rq.netblockRequests(netblock),
|
||||
}
|
||||
|
||||
percent := rq.limitPercent
|
||||
@@ -174,13 +175,14 @@ func (rq *request) banForAttack(now time.Time, rule rules.Rule) {
|
||||
}
|
||||
|
||||
notes := bans.Notes{
|
||||
ASN: rq.line.ASN,
|
||||
ASName: rq.line.ASName,
|
||||
Country: rq.line.Country,
|
||||
RuleID: rule.ID,
|
||||
Target: rule.Target,
|
||||
Request: rq.noted(now, rq.h.config.BanResponse),
|
||||
Requests: rq.netblockRequests(netblock),
|
||||
ASN: rq.line.ASN,
|
||||
ASName: rq.line.ASName,
|
||||
Country: rq.line.Country,
|
||||
RuleID: rule.ID,
|
||||
Target: rule.Target,
|
||||
Reputation: rq.reputation,
|
||||
Request: rq.noted(now, rq.h.config.BanResponse),
|
||||
Requests: rq.netblockRequests(netblock),
|
||||
}
|
||||
|
||||
if rq.h.config.Observe {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -312,7 +313,7 @@ func TestBanNotes(t *testing.T) {
|
||||
ledger := server.Ledger
|
||||
|
||||
got := ledger.Bans(netblock)
|
||||
if len(got) != 1 || got[0] != want {
|
||||
if len(got) != 1 || !reflect.DeepEqual(got[0], want) {
|
||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -337,7 +338,7 @@ func TestBanForABrokenByteLimitHasItsNotesAndItsAlert(t *testing.T) {
|
||||
}
|
||||
|
||||
got := server.Ledger.Bans(netblock)
|
||||
if len(got) != 1 || got[0] != want {
|
||||
if len(got) != 1 || !reflect.DeepEqual(got[0], want) {
|
||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -126,7 +127,7 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
|
||||
}
|
||||
|
||||
got := server.Ledger.Snapshot()
|
||||
if len(got) != 1 || got[0] != kept {
|
||||
if len(got) != 1 || !reflect.DeepEqual(got[0], kept) {
|
||||
t.Errorf("bans\n%+v\nwant only\n%+v", got, kept)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
)
|
||||
@@ -62,29 +63,34 @@ func (rq *request) abuseIPDBDenied(ctx context.Context) bool {
|
||||
}
|
||||
|
||||
rq.abuseIPDBHit = true
|
||||
rq.noteHit(reputation.AbuseIPDBSource, "scored by AbuseIPDB at or over "+
|
||||
"SWWAF_ABUSEIPDB_MIN_SCORE", map[string]any{
|
||||
"source": reputation.AbuseIPDBSource, "score": score,
|
||||
})
|
||||
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
|
||||
"scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE")
|
||||
|
||||
return rq.h.config.ReputationAction == deny
|
||||
}
|
||||
|
||||
// noteListed notes each of sources, the URLs of the blocklists or the
|
||||
// DNSBL zones, their keys masked, that list the client, as noteHit does,
|
||||
// with reason, and the source in the alert's detail.
|
||||
// with reason.
|
||||
func (rq *request) noteListed(sources []string, reason string) {
|
||||
for _, source := range sources {
|
||||
rq.noteHit(source, reason, map[string]any{"source": source})
|
||||
rq.noteHit(bans.ReputationHit{Source: source}, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// noteHit adds source, which lists the client, to the log line's
|
||||
// reputation, counts it in the metrics, and raises a reputation_hit alert
|
||||
// with reason and detail.
|
||||
func (rq *request) noteHit(source, reason string, detail map[string]any) {
|
||||
rq.line.Reputation = append(rq.line.Reputation, source)
|
||||
rq.h.metrics.ReputationHit(source)
|
||||
// noteHit adds hit's source, which lists the client, to the log line's
|
||||
// reputation, and hit to the notes of a ban the request makes, counts the
|
||||
// source in the metrics, and raises a reputation_hit alert with reason,
|
||||
// whose detail gives hit's source and score.
|
||||
func (rq *request) noteHit(hit bans.ReputationHit, reason string) {
|
||||
detail := map[string]any{"source": hit.Source}
|
||||
if hit.Score != nil {
|
||||
detail["score"] = *hit.Score
|
||||
}
|
||||
|
||||
rq.line.Reputation = append(rq.line.Reputation, hit.Source)
|
||||
rq.reputation = append(rq.reputation, hit)
|
||||
rq.h.metrics.ReputationHit(hit.Source)
|
||||
rq.h.alerts.Raise(alerts.Alert{
|
||||
Event: alerts.EventReputationHit,
|
||||
Client: rq.client,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
@@ -71,7 +72,10 @@ type request struct {
|
||||
// dnsblListed once a DNSBL zone's verdict is, and abuseIPDBHit once
|
||||
// AbuseIPDB's score of it is a hit.
|
||||
blocklisted, dnsblListed, abuseIPDBHit bool
|
||||
start time.Time
|
||||
// reputation is the reputation sources that list the client, for the
|
||||
// notes of a ban the request makes.
|
||||
reputation []bans.ReputationHit
|
||||
start time.Time
|
||||
// checked is when the checks were done, and upstreamStart when the
|
||||
// request was handed to the app.
|
||||
checked time.Time
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -73,7 +74,7 @@ func TestEachRuleAction(t *testing.T) {
|
||||
}
|
||||
|
||||
got := server.Ledger.Bans(netblock)
|
||||
if len(got) != 1 || got[0] != want {
|
||||
if len(got) != 1 || !reflect.DeepEqual(got[0], want) {
|
||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user