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; source_failure for GeoJS; file_error for a rule or state file edit that does not parse and a failed state write. SWWAF_ALERT_EVENTS chooses, SWWAF_ALERT_COOLDOWN holds back repeats, and past SWWAF_ALERT_MAX_PER_HOUR the hour ends in one summary. A bounded queue, retried with backoff, holds up no request; alerts.json keeps it, the cooldowns and the hour. The ledger now reports whether it made a ban, or made one permanent. Judgement call: the summary's event is summary, which SPEC.md omits. Judgement call: admin bans and observe mode raise no alert. Model: opus-5-5
This commit is contained in:
+162
-10
@@ -22,6 +22,7 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||
)
|
||||
|
||||
@@ -158,6 +159,19 @@ type Config struct {
|
||||
LogRemoteBuffer int
|
||||
LogRemoteFacility int
|
||||
LogRemoteAppName string
|
||||
// AlertWebhookURL is where each alert is posted as JSON
|
||||
// (SWWAF_ALERT_WEBHOOK_URL), nil while it is unset and no alert is
|
||||
// sent. AlertWebhookHeaders are sent with each
|
||||
// (SWWAF_ALERT_WEBHOOK_HEADERS). AlertEvents are the events alerts are
|
||||
// sent for (SWWAF_ALERT_EVENTS). A repeat of an alert within
|
||||
// AlertCooldown is held back (SWWAF_ALERT_COOLDOWN), and so is an alert
|
||||
// past AlertMaxPerHour in an hour, for the hour's summary
|
||||
// (SWWAF_ALERT_MAX_PER_HOUR); 0 is off for both.
|
||||
AlertWebhookURL *url.URL
|
||||
AlertWebhookHeaders http.Header
|
||||
AlertEvents []string
|
||||
AlertCooldown time.Duration
|
||||
AlertMaxPerHour int
|
||||
|
||||
// settings are the values read, as given or by default, and the
|
||||
// files they were read from, for the log line at start.
|
||||
@@ -230,7 +244,17 @@ var (
|
||||
errNotFacility = errors.New("is not a syslog facility such as local0 or daemon")
|
||||
errNotAppName = errors.New(
|
||||
"is not 1 to 48 printable ASCII characters without a space, such as gitea")
|
||||
errSetTwice = errors.New("set only one of them")
|
||||
errSetTwice = errors.New("set only one of them")
|
||||
errNotWebhookURL = errors.New(
|
||||
"is not an http or https URL without a user or a fragment, " +
|
||||
"such as https://alerts.example/smallwebwaf")
|
||||
errNotWebhookHeader = errors.New(
|
||||
"is not a header name followed by : and the header's value, " +
|
||||
"such as Authorization:Bearer <token>")
|
||||
errNotAlertEvent = errors.New(
|
||||
"is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
|
||||
"source_failure or file_error")
|
||||
errNotNumberOrOff = errors.New("is not a whole number above zero, such as 60, or off")
|
||||
)
|
||||
|
||||
// FromEnvironment reads the settings with lookupEnv, normally
|
||||
@@ -278,15 +302,21 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||
StateCounterInterval: env.durationNotOff("SWWAF_STATE_COUNTER_INTERVAL", "15m"),
|
||||
LogRequestHeaders: env.headerNames("SWWAF_LOG_REQUEST_HEADERS",
|
||||
"accept,accept-language,accept-encoding,content-type,origin,range"),
|
||||
AdminToken: env.token("SWWAF_ADMIN_TOKEN"),
|
||||
MetricsToken: env.token("SWWAF_METRICS_TOKEN"),
|
||||
MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"),
|
||||
RulesDir: env.value("SWWAF_RULES_DIR", "/etc/smallwebwaf/rules.d"),
|
||||
RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"),
|
||||
LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"),
|
||||
LogRemoteTLSCAs: env.certificates("SWWAF_LOG_REMOTE_TLS_CA_FILE"),
|
||||
LogRemoteBuffer: env.numberNotOff("SWWAF_LOG_REMOTE_BUFFER", "10000"),
|
||||
LogRemoteFacility: env.facility("SWWAF_LOG_REMOTE_FACILITY", "local0"),
|
||||
AdminToken: env.token("SWWAF_ADMIN_TOKEN"),
|
||||
MetricsToken: env.token("SWWAF_METRICS_TOKEN"),
|
||||
MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"),
|
||||
RulesDir: env.value("SWWAF_RULES_DIR", "/etc/smallwebwaf/rules.d"),
|
||||
RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"),
|
||||
LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"),
|
||||
LogRemoteTLSCAs: env.certificates("SWWAF_LOG_REMOTE_TLS_CA_FILE"),
|
||||
LogRemoteBuffer: env.numberNotOff("SWWAF_LOG_REMOTE_BUFFER", "10000"),
|
||||
LogRemoteFacility: env.facility("SWWAF_LOG_REMOTE_FACILITY", "local0"),
|
||||
AlertWebhookURL: env.webhookURL("SWWAF_ALERT_WEBHOOK_URL"),
|
||||
AlertWebhookHeaders: env.webhookHeaders("SWWAF_ALERT_WEBHOOK_HEADERS"),
|
||||
AlertEvents: env.alertEvents("SWWAF_ALERT_EVENTS",
|
||||
strings.Join(alerts.Events(), ",")),
|
||||
AlertCooldown: env.duration("SWWAF_ALERT_COOLDOWN", "15m"),
|
||||
AlertMaxPerHour: env.numberOrOff("SWWAF_ALERT_MAX_PER_HOUR", "60"),
|
||||
}
|
||||
|
||||
cfg.LogRemoteAppName = env.appName("SWWAF_LOG_REMOTE_APP_NAME",
|
||||
@@ -636,6 +666,49 @@ func (e *environment) appName(name, instanceName string, sending bool) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// webhookURL reads the setting that is where each alert is posted. Unset
|
||||
// or empty, it is nil, and no alert is sent.
|
||||
func (e *environment) webhookURL(name string) *url.URL {
|
||||
value := e.value(name, "")
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
webhook, err := parseWebhookURL(value)
|
||||
e.check(name, err)
|
||||
|
||||
return webhook
|
||||
}
|
||||
|
||||
// webhookHeaders reads the setting that is the headers sent with each
|
||||
// alert. The log shows each header's value as ********, since a header
|
||||
// such as Authorization carries a secret.
|
||||
func (e *environment) webhookHeaders(name string) http.Header {
|
||||
value, _ := e.lookup(name)
|
||||
headers, logged, err := parseWebhookHeaders(value)
|
||||
e.settings = append(e.settings, slog.String(name, logged))
|
||||
e.check(name, err)
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
// alertEvents reads the setting that is the events alerts are sent for.
|
||||
func (e *environment) alertEvents(name, defaultValue string) []string {
|
||||
events, err := parseAlertEvents(e.value(name, defaultValue))
|
||||
e.check(name, err)
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
// numberOrOff reads a setting that is a whole number above zero, or off,
|
||||
// which is 0.
|
||||
func (e *environment) numberOrOff(name, defaultValue string) int {
|
||||
number, err := parseNumberOrOff(e.value(name, defaultValue))
|
||||
e.check(name, err)
|
||||
|
||||
return number
|
||||
}
|
||||
|
||||
// parseDuration reads a duration in Go's syntax, such as 90s or 15m, a
|
||||
// whole number of days such as 7d, or off.
|
||||
func parseDuration(value string) (time.Duration, error) {
|
||||
@@ -1051,6 +1124,85 @@ func parseFacility(value string) (int, error) {
|
||||
return number, nil
|
||||
}
|
||||
|
||||
// parseWebhookURL reads where each alert is posted: http or https, a
|
||||
// host, and an optional port from 1 to 65535, path and query, without a
|
||||
// user or a fragment.
|
||||
func parseWebhookURL(value string) (*url.URL, error) {
|
||||
webhook, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q %w", value, errNotWebhookURL)
|
||||
}
|
||||
|
||||
port, err := strconv.ParseUint(webhook.Port(), 10, 16)
|
||||
|
||||
valid := (webhook.Scheme == "http" || webhook.Scheme == "https") &&
|
||||
webhook.Hostname() != "" && (webhook.Port() == "" || (err == nil && port != 0)) &&
|
||||
webhook.User == nil && webhook.Opaque == "" && webhook.Fragment == ""
|
||||
if !valid {
|
||||
return nil, fmt.Errorf("%q %w", value, errNotWebhookURL)
|
||||
}
|
||||
|
||||
return webhook, nil
|
||||
}
|
||||
|
||||
// parseWebhookHeaders reads a comma-separated list of headers, each its
|
||||
// name, :, and its value, and returns them, and how the log shows them,
|
||||
// with each value as ********. An error names the item by its place in
|
||||
// the list, so that it shows no value. An empty value is an empty list.
|
||||
func parseWebhookHeaders(value string) (http.Header, string, error) {
|
||||
headers := http.Header{}
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return headers, "", nil
|
||||
}
|
||||
|
||||
logged := []string{}
|
||||
|
||||
for i, item := range strings.Split(value, ",") {
|
||||
name, headerValue, found := strings.Cut(item, ":")
|
||||
name = strings.TrimSpace(name)
|
||||
|
||||
if !found || !IsHeaderName(name) || strings.ContainsAny(headerValue, "\r\n\x00") {
|
||||
return nil, "", fmt.Errorf("item %d %w", i+1, errNotWebhookHeader)
|
||||
}
|
||||
|
||||
headers.Add(name, strings.TrimSpace(headerValue))
|
||||
logged = append(logged, name+":"+masked)
|
||||
}
|
||||
|
||||
return headers, strings.Join(logged, ","), nil
|
||||
}
|
||||
|
||||
// parseAlertEvents reads a comma-separated list of the events alerts can
|
||||
// be sent for.
|
||||
func parseAlertEvents(value string) ([]string, error) {
|
||||
events, err := parseList(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, event := range events {
|
||||
if !slices.Contains(alerts.Events(), event) {
|
||||
return nil, fmt.Errorf("%q %w", event, errNotAlertEvent)
|
||||
}
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// parseNumberOrOff reads a whole number above zero, or off, which is 0.
|
||||
func parseNumberOrOff(value string) (int, error) {
|
||||
if value == off {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(value)
|
||||
if err != nil || n <= 0 {
|
||||
return 0, fmt.Errorf("%q %w", value, errNotNumberOrOff)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// appNameMaxLength is the most characters RFC 5424 allows in an
|
||||
// APP-NAME.
|
||||
const appNameMaxLength = 48
|
||||
|
||||
Reference in New Issue
Block a user