check / check (push) Waiting to run
Each check servewaf runs on a request is a type in its own file behind one interface, heuristic, and is registered in one of four ordered lists in heuristics.go. Order and behaviour are unchanged. Every refusal now logs heuristic, the name of the heuristic that made it, and heuristic_figures, the figures it acted on. A ban by refusal_limit or byte_limits after the answer logs report_heuristic and report_heuristic_figures instead, so a line can name both. All four are in docs/request-log.md, and SPEC.md names each heuristic at its step. Model: opus-5-5
197 lines
7.2 KiB
Go
197 lines
7.2 KiB
Go
package servewaf_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/netip"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
)
|
|
|
|
// The CrowdSec settings, and the tests' engine, which is never asked: each
|
|
// test puts in the copy of its decision list, at decisionsURL, that it
|
|
// needs, as reputation.json would at start.
|
|
const (
|
|
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL"
|
|
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY"
|
|
lapi = "http://crowdsec.example:8080"
|
|
decisionsURL = lapi + "/v1/decisions"
|
|
bouncerKey = "crowdsec-key-0123456789abcdef"
|
|
)
|
|
|
|
func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
|
crowdSecURL: lapi, crowdSecKey: bouncerKey, metricsToken: token,
|
|
})
|
|
// client had four hours left on its decision as the engine answered.
|
|
fetched := clk.Now()
|
|
loadDecisions(t, server, fetched, `[{"duration": "4h0m0s", `+
|
|
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
|
|
`"value": "`+client+`"}]`)
|
|
expires := requestlog.FormatTime(fetched.Add(4 * time.Hour))
|
|
|
|
// Its first request is refused, and bans it until the decision ends.
|
|
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
|
wantReputation(t, line, decisionsURL)
|
|
|
|
if line.BanExpires != expires {
|
|
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires, expires)
|
|
}
|
|
|
|
listed := []bans.ReputationHit{{Source: decisionsURL}}
|
|
|
|
held := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
|
if len(held) != 1 || held[0].Cause != bans.CauseCrowdSec ||
|
|
!held[0].Start.Equal(fetched) || !held[0].Expires.Equal(fetched.Add(4*time.Hour)) ||
|
|
held[0].Reason != "CrowdSec's decision for crowdsecurity/ssh-bf" ||
|
|
!reflect.DeepEqual(held[0].Notes.Reputation, listed) ||
|
|
held[0].Notes.Request.Path != "/" || held[0].Notes.Requests != 1 {
|
|
t.Fatalf("bans %+v, want one for crowdsec of four hours, with the list and "+
|
|
"the request in its notes", held)
|
|
}
|
|
|
|
// The listing raises a reputation_hit alert, and the ban its own.
|
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
|
if len(waiting) != 2 || waiting[0].Event != alerts.EventReputationHit ||
|
|
waiting[0].Reason != "listed by the CrowdSec decision list" ||
|
|
!reflect.DeepEqual(waiting[1], banAlert(alerts.EventBan, fetched, client, held[0],
|
|
expires)) {
|
|
t.Errorf("alerts waiting %+v, want a reputation_hit alert, then the ban's",
|
|
waiting)
|
|
}
|
|
|
|
// Each request while the ban lasts is refused under it, as under any
|
|
// ban, and once it has ended the client is let through.
|
|
clk.advance(4*time.Hour - time.Second)
|
|
|
|
line = s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
|
wantReputation(t, line)
|
|
|
|
if line.BanExpires != expires {
|
|
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires, expires)
|
|
}
|
|
|
|
clk.advance(time.Second)
|
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
|
|
// The ban and the hit are counted, and the list has the metrics of any
|
|
// list fetched from a URL.
|
|
metrics := s.scrape()
|
|
labels := `{instance="` + alertInstance + `",source="` + decisionsURL + `"}`
|
|
|
|
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="crowdsec",`+
|
|
`instance="`+alertInstance+`"}`, 1)
|
|
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 1)
|
|
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
|
wantMetric(t, metrics, "smallwebwaf_reputation_last_fetch_timestamp_seconds"+labels,
|
|
float64(fetched.Unix()))
|
|
}
|
|
|
|
func TestCrowdSecBanNamesItsHeuristicScenarioAndEnd(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, clk, server, _ := startWithAlerts(t, map[string]string{
|
|
crowdSecURL: lapi, crowdSecKey: bouncerKey,
|
|
})
|
|
loadDecisions(t, server, clk.Now(), `[{"duration": "4h0m0s", `+
|
|
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
|
|
`"value": "`+client+`"}]`)
|
|
|
|
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
|
wantHeuristic(t, line, "crowdsec", `{"scenario": "crowdsecurity/ssh-bf", `+
|
|
`"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`)
|
|
}
|
|
|
|
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, clk, server := startWithClock(t, "", map[string]string{
|
|
crowdSecURL: lapi, crowdSecKey: bouncerKey,
|
|
})
|
|
// 198.51.100.0/24 and 2001:db8::9 had a minute left as the engine
|
|
// answered.
|
|
fetched := clk.Now()
|
|
loadDecisions(t, server, fetched, `[{"duration": "1m0s", `+
|
|
`"scenario": "crowdsecurity/http-probing", "scope": "Range", "type": "ban", `+
|
|
`"value": "198.51.100.0/24"}, {"duration": "1m0s", `+
|
|
`"scenario": "crowdsecurity/http-probing", "scope": "Ip", "type": "ban", `+
|
|
`"value": "2001:db8::9"}]`)
|
|
|
|
// Just before its end, the decision bans a client in the netblock, and
|
|
// one on an IPv6 address bans the address's group, the /64.
|
|
clk.advance(time.Minute - time.Nanosecond)
|
|
s.get("198.51.100.7", http.StatusForbidden, requestlog.ActionBanned)
|
|
s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionBanned)
|
|
s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionBanned)
|
|
|
|
// Once it has ended, it bans no other client, and the bans it made end
|
|
// with it.
|
|
clk.advance(time.Nanosecond)
|
|
|
|
for _, from := range []string{"198.51.100.8", "198.51.100.7", "2001:db8::5"} {
|
|
wantReputation(t, s.get(from, http.StatusOK, requestlog.ActionForward))
|
|
}
|
|
|
|
if made := server.Ledger.Made(bans.CauseCrowdSec); made != 2 {
|
|
t.Errorf("%d bans made for crowdsec, want 2, on 198.51.100.7/32 and "+
|
|
"2001:db8::/64", made)
|
|
}
|
|
|
|
if held := server.Ledger.Bans(netip.MustParsePrefix("2001:db8::/64")); len(held) != 1 {
|
|
t.Errorf("bans of 2001:db8::/64 %+v, want one", held)
|
|
}
|
|
}
|
|
|
|
func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
|
crowdSecURL: lapi, crowdSecKey: bouncerKey, mode: observe,
|
|
})
|
|
loadDecisions(t, server, clk.Now(), `[{"duration": "4h0m0s", `+
|
|
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
|
|
`"value": "`+client+`"}]`)
|
|
|
|
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
wantWouldAction(t, line, requestlog.ActionBanned)
|
|
wantReputation(t, line, decisionsURL)
|
|
|
|
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
|
t.Errorf("bans %+v, want none", held)
|
|
}
|
|
|
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
|
if len(waiting) != 2 || waiting[1].Event != alerts.EventBan ||
|
|
waiting[1].Detail["cause"] != bans.CauseCrowdSec ||
|
|
waiting[1].Detail["mode"] != observe {
|
|
t.Errorf("alerts waiting %+v, want a reputation_hit alert, then the ban alert "+
|
|
"marked observe", waiting)
|
|
}
|
|
}
|
|
|
|
// loadDecisions puts into server's lists the copy of the decision list at
|
|
// decisionsURL, answer, the engine's answer, fetched at fetched, as
|
|
// reputation.json would at start.
|
|
func loadDecisions(
|
|
t *testing.T, server *servewaf.Server, fetched time.Time, answer string,
|
|
) {
|
|
t.Helper()
|
|
|
|
err := server.Lists.Load([]reputation.List{{
|
|
URL: decisionsURL, Tried: fetched, Fetched: fetched, Lines: []string{answer},
|
|
}})
|
|
if err != nil {
|
|
t.Fatalf("load the decision list: %v", err)
|
|
}
|
|
}
|