Alerts to a JSON webhook, with a cooldown and an hourly summary (closes #26)
check / check (push) Waiting to run
check / check (push) Waiting to run
SWWAF_ALERT_WEBHOOK_URL gets one JSON POST per alert, in SPEC.md's schema, with SWWAF_ALERT_WEBHOOK_HEADERS: ban and permanent_ban, with the ban's notes, in observe mode too, marked mode observe; source_failure for GeoJS; file_error for a rule or state file with an error. SWWAF_ALERT_EVENTS chooses; SWWAF_ALERT_COOLDOWN holds back repeats by netblock, file or source; past SWWAF_ALERT_MAX_PER_HOUR the hour ends in one summary. A bounded queue, retried with backoff, holds up no request; a 4xx other than 408 and 429 gives the alert up. alerts.json keeps the queue, the cooldowns and the hour. Nothing shows the URL's path or query. Judgement call: the summary's event is summary, which SPEC.md omits. Judgement call: an admin's ban raises no alert. Model: opus-5-5
This commit is contained in:
@@ -7,12 +7,15 @@ import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
)
|
||||
|
||||
@@ -337,7 +340,7 @@ func TestEditsTakenInWhileRunning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := writeFiles(t, ruleFiles{firstFile: "first path block ^/first\n"})
|
||||
files, lines := watch(t, dir)
|
||||
files, lines, _ := watch(t, dir)
|
||||
|
||||
// matches reports whether path matches a rule.
|
||||
matches := func(path string) bool { return len(files.Match(get(t, path))) == 1 }
|
||||
@@ -366,7 +369,7 @@ func TestBrokenEditKeepsTheRulesAsTheyWere(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := writeFiles(t, ruleFiles{firstFile: "first path block ^/first\n"})
|
||||
files, lines := watch(t, dir)
|
||||
files, lines, queue := watch(t, dir)
|
||||
|
||||
// The edit's second line has an unknown action, so the rules stay as
|
||||
// they were, the first line's earlier version included.
|
||||
@@ -380,13 +383,27 @@ func TestBrokenEditKeepsTheRulesAsTheyWere(t *testing.T) {
|
||||
t.Errorf("logged %v, want an error %q", line, want)
|
||||
}
|
||||
|
||||
// The error is raised as a file_error alert too, for the file.
|
||||
wantFileError := func() {
|
||||
t.Helper()
|
||||
|
||||
waiting := queue.Snapshot().Waiting
|
||||
if len(waiting) != 1 || waiting[0].Event != alerts.EventFileError ||
|
||||
waiting[0].Reason != hasError || waiting[0].Detail["error"] != want ||
|
||||
waiting[0].Detail["file"] != filepath.Join(dir, firstFile) {
|
||||
t.Errorf("alerts waiting %+v, want one file_error alert for %q", waiting, want)
|
||||
}
|
||||
}
|
||||
wantFileError()
|
||||
|
||||
wantMatched(t, files, get(t, "/first"), "first")
|
||||
wantMatched(t, files, get(t, "/second"))
|
||||
|
||||
// Once mended, the file is read again.
|
||||
// Once mended, the file is read again, and raises no alert.
|
||||
save(t, dir, firstFile, "first path block ^/edited\nsecond path ban ^/second\n")
|
||||
lines.waitUntil(t, func() bool { return len(files.Match(get(t, "/second"))) == 1 })
|
||||
wantMatched(t, files, get(t, "/edited"), "first")
|
||||
wantFileError()
|
||||
}
|
||||
|
||||
func TestDefaultFileBansProbesAtTheSiteRootAlone(t *testing.T) {
|
||||
@@ -504,7 +521,8 @@ func save(t *testing.T, dir, name, content string) {
|
||||
}
|
||||
|
||||
// newParams returns Params for the rule files in dir, switched on, with
|
||||
// the process log in the processLog returned.
|
||||
// the process log in the processLog returned, and the alerts waiting in a
|
||||
// queue for a webhook that is never sent them.
|
||||
func newParams(dir string) (rules.Params, processLog) {
|
||||
lines := make(processLog, maxLogLines)
|
||||
|
||||
@@ -512,6 +530,12 @@ func newParams(dir string) (rules.Params, processLog) {
|
||||
Dir: dir,
|
||||
Enabled: true,
|
||||
ProcessLog: slog.New(slog.NewJSONHandler(lines, nil)),
|
||||
Alerts: alerts.New(alerts.Params{
|
||||
WebhookURL: &url.URL{Scheme: "https", Host: "alerts.example"},
|
||||
Events: alerts.Events(),
|
||||
Cooldown: 15 * time.Minute,
|
||||
Now: time.Now,
|
||||
}),
|
||||
}, lines
|
||||
}
|
||||
|
||||
@@ -531,8 +555,9 @@ func load(t *testing.T, files ruleFiles) *rules.Files {
|
||||
}
|
||||
|
||||
// watch loads the rules in dir, runs their Watch until the test ends, and
|
||||
// waits until it watches the directory.
|
||||
func watch(t *testing.T, dir string) (*rules.Files, processLog) {
|
||||
// waits until it watches the directory. It returns the alerts' queue as
|
||||
// well.
|
||||
func watch(t *testing.T, dir string) (*rules.Files, processLog, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
params, lines := newParams(dir)
|
||||
@@ -557,7 +582,7 @@ func watch(t *testing.T, dir string) (*rules.Files, processLog) {
|
||||
|
||||
lines.waitFor(t, watching)
|
||||
|
||||
return files, lines
|
||||
return files, lines, params.Alerts
|
||||
}
|
||||
|
||||
// wantRefused checks that loading the rule files in dir fails with the
|
||||
|
||||
Reference in New Issue
Block a user