check / check (push) Failing after 3m27s
Every setting of discriminator and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed. Discriminator's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Every place a name appeared changed: the code, the viper keys, tests, documents, examples, the README's settings table and the image files. Model: deepseek-v4-pro
182 lines
6.6 KiB
Go
182 lines
6.6 KiB
Go
package discriminator_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/discriminator"
|
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
)
|
|
|
|
// 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 = "SWW_WAF_CROWDSEC_LAPI_URL"
|
|
crowdSecKey = "SWW_WAF_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, `sww_bans_made_total{cause="crowdsec",`+
|
|
`instance="`+alertInstance+`"}`, 1)
|
|
wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 1)
|
|
wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
|
|
wantMetric(t, metrics, "sww_reputation_last_fetch_timestamp_seconds"+labels,
|
|
float64(fetched.Unix()))
|
|
}
|
|
|
|
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 *discriminator.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)
|
|
}
|
|
}
|