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:
@@ -6,9 +6,11 @@ import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -62,6 +64,19 @@ const (
|
||||
logRemoteBuffer = "SWWAF_LOG_REMOTE_BUFFER"
|
||||
logRemoteFacility = "SWWAF_LOG_REMOTE_FACILITY"
|
||||
logRemoteAppName = "SWWAF_LOG_REMOTE_APP_NAME"
|
||||
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL"
|
||||
alertWebhookHeaders = "SWWAF_ALERT_WEBHOOK_HEADERS"
|
||||
alertEvents = "SWWAF_ALERT_EVENTS"
|
||||
alertCooldown = "SWWAF_ALERT_COOLDOWN"
|
||||
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR"
|
||||
)
|
||||
|
||||
// defaultAlertEvents is the default of SWWAF_ALERT_EVENTS, and
|
||||
// defaultAlertCooldown that of SWWAF_ALERT_COOLDOWN.
|
||||
const (
|
||||
defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," +
|
||||
"source_failure,file_error"
|
||||
defaultAlertCooldown = "15m"
|
||||
)
|
||||
|
||||
// defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS.
|
||||
@@ -477,6 +492,150 @@ func TestAppNameSetStopsTheStartWhileSending(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertSettingsDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{})
|
||||
|
||||
if cfg.AlertWebhookURL != nil || len(cfg.AlertWebhookHeaders) != 0 ||
|
||||
strings.Join(cfg.AlertEvents, ",") != defaultAlertEvents ||
|
||||
cfg.AlertCooldown != 15*time.Minute || cfg.AlertMaxPerHour != 60 {
|
||||
t.Errorf("alert settings %v, %v, %v, %s and %d, want no URL, no headers, "+
|
||||
"%s, 15m and 60", cfg.AlertWebhookURL, cfg.AlertWebhookHeaders,
|
||||
cfg.AlertEvents, cfg.AlertCooldown, cfg.AlertMaxPerHour, defaultAlertEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertSettingsAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const webhook = "https://alerts.example:8443/hooks/waf?team=ops"
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
alertWebhookURL: webhook,
|
||||
alertWebhookHeaders: "Authorization: Bearer abc:def , x-team:ops",
|
||||
alertEvents: "ban, file_error",
|
||||
alertCooldown: "1h",
|
||||
alertMaxPerHour: "10",
|
||||
})
|
||||
|
||||
headers := http.Header{"Authorization": {"Bearer abc:def"}, "X-Team": {"ops"}}
|
||||
if cfg.AlertWebhookURL.String() != webhook ||
|
||||
!reflect.DeepEqual(cfg.AlertWebhookHeaders, headers) ||
|
||||
!slices.Equal(cfg.AlertEvents, []string{"ban", "file_error"}) ||
|
||||
cfg.AlertCooldown != time.Hour || cfg.AlertMaxPerHour != 10 {
|
||||
t.Errorf("alert settings %v, %v, %v, %s and %d", cfg.AlertWebhookURL,
|
||||
cfg.AlertWebhookHeaders, cfg.AlertEvents, cfg.AlertCooldown,
|
||||
cfg.AlertMaxPerHour)
|
||||
}
|
||||
|
||||
cfg = fromEnvironment(t, environment{
|
||||
alertWebhookURL: "", alertEvents: "", alertCooldown: off, alertMaxPerHour: off,
|
||||
})
|
||||
if cfg.AlertWebhookURL != nil || len(cfg.AlertEvents) != 0 ||
|
||||
cfg.AlertCooldown != 0 || cfg.AlertMaxPerHour != 0 {
|
||||
t.Errorf("set empty or off, alert settings %v, %v, %s and %d",
|
||||
cfg.AlertWebhookURL, cfg.AlertEvents, cfg.AlertCooldown, cfg.AlertMaxPerHour)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidAlertSettingStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantStartStopped(t, []struct{ name, value string }{
|
||||
{alertWebhookURL, "alerts.example/smallwebwaf"},
|
||||
{alertWebhookURL, "ftp://alerts.example/"},
|
||||
{alertWebhookURL, "https:///smallwebwaf"},
|
||||
{alertWebhookURL, "https://user:password@alerts.example/"},
|
||||
{alertWebhookURL, "https://alerts.example/#top"},
|
||||
{alertWebhookURL, "https://alerts.example:0/"},
|
||||
{alertWebhookURL, "https://alerts.example:65536/"},
|
||||
{alertWebhookHeaders, "Authorization"},
|
||||
{alertWebhookHeaders, "X Team:ops"},
|
||||
{alertWebhookHeaders, ":ops"},
|
||||
{alertWebhookHeaders, "X-Team:ops,"},
|
||||
{alertWebhookHeaders, "X-Team:o\r\nps"},
|
||||
{alertEvents, "bans"},
|
||||
{alertEvents, "summary"},
|
||||
{alertEvents, "ban,,file_error"},
|
||||
{alertCooldown, "0"},
|
||||
{alertCooldown, "soon"},
|
||||
{alertMaxPerHour, "0"},
|
||||
{alertMaxPerHour, "-1"},
|
||||
{alertMaxPerHour, "1.5"},
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhookHeadersAreLoggedMaskedAndNeverShown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const secret = "Bearer 0123456789abcdef"
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
alertWebhookHeaders: "Authorization:" + secret + ",X-Team:ops",
|
||||
})
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
|
||||
|
||||
logged := out.String()
|
||||
if strings.Contains(logged, secret) || strings.Contains(logged, "ops") ||
|
||||
!strings.Contains(logged,
|
||||
`"`+alertWebhookHeaders+`":"Authorization:********,X-Team:********"`) {
|
||||
t.Errorf("the headers are not logged masked: %s", logged)
|
||||
}
|
||||
|
||||
// An item that is not a header is named by its place, not shown.
|
||||
_, err := config.FromEnvironment(environment{
|
||||
alertWebhookHeaders: "X-Team:ops," + secret,
|
||||
}.lookupEnv)
|
||||
|
||||
want := alertWebhookHeaders + ": item 2 is not a header name followed by : " +
|
||||
"and the header's value, such as Authorization:Bearer <token>"
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const secret = "T0123/B4567/abcdef"
|
||||
|
||||
for value, want := range map[string]string{
|
||||
"https://hooks.example/services/" + secret: "https://hooks.example/********",
|
||||
"https://hooks.example:8443?token=" + secret: "https://hooks.example:8443/********",
|
||||
"http://[2001:db8::1]:8080": "http://[2001:db8::1]:8080",
|
||||
} {
|
||||
cfg := fromEnvironment(t, environment{alertWebhookURL: value})
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
|
||||
|
||||
logged := out.String()
|
||||
if strings.Contains(logged, secret) ||
|
||||
!strings.Contains(logged, `"`+alertWebhookURL+`":"`+want+`"`) {
|
||||
t.Errorf("%s is not logged as %s: %s", value, want, logged)
|
||||
}
|
||||
}
|
||||
|
||||
// A value that is not such a URL is not shown either.
|
||||
for _, value := range []string{
|
||||
"ftp://hooks.example/services/" + secret,
|
||||
"https://hooks.example/services/%zz" + secret,
|
||||
} {
|
||||
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv)
|
||||
|
||||
want := alertWebhookURL + ": is not an http or https URL without a user or " +
|
||||
"a fragment, such as https://alerts.example/smallwebwaf"
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -926,6 +1085,11 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
logRemoteBuffer: "10000",
|
||||
logRemoteFacility: "local0",
|
||||
logRemoteAppName: hostname,
|
||||
alertWebhookURL: "",
|
||||
alertWebhookHeaders: "",
|
||||
alertEvents: defaultAlertEvents,
|
||||
alertCooldown: defaultAlertCooldown,
|
||||
alertMaxPerHour: "60",
|
||||
}
|
||||
if !maps.Equal(line.Settings, want) {
|
||||
t.Errorf("logged settings\n%v\nwant\n%v", line.Settings, want)
|
||||
|
||||
Reference in New Issue
Block a user