Alerts to a JSON webhook, with a cooldown and an hourly summary (closes #26)
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:
2026-10-07 00:12:21 +00:00
parent 5d6f6ffaf9
commit a8e86c18db
24 changed files with 2768 additions and 209 deletions
+47 -6
View File
@@ -4,6 +4,7 @@ import (
"net/netip"
"time"
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
@@ -16,14 +17,25 @@ func (rq *request) banResponse(action string) *refusal {
}
// banned reports whether a ban on a netblock the client is in covers the
// request at now, and notes for the log line when that ban ends.
// request at now, and notes for the log line when that ban ends. A
// request that makes the ban permanent raises the alert for it.
func (rq *request) banned(now time.Time) bool {
check := rq.h.ledger.Check
var (
ban bans.Ban
banned bool
madePermanent bool
)
if rq.h.config.Observe {
check = rq.h.ledger.Find // in observe mode the ban refuses nothing
ban, banned = rq.h.ledger.Find(rq.client, now) // the ban refuses nothing
} else {
ban, banned, madePermanent = rq.h.ledger.Check(rq.client, now)
}
if madePermanent {
rq.alertBan(ban)
}
ban, banned := check(rq.client, now)
if banned {
rq.line.BanExpires = banExpires(ban)
}
@@ -54,7 +66,7 @@ func (rq *request) limitBroken(now time.Time) bool {
}
netblock := rq.h.netblock(rq.client)
ban := rq.h.ledger.BanForLimit(netblock, now, bans.Notes{
ban, made := rq.h.ledger.BanForLimit(netblock, now, bans.Notes{
Country: rq.line.Country,
Limit: hit.Limit,
Window: hit.Window,
@@ -65,6 +77,10 @@ func (rq *request) limitBroken(now time.Time) bool {
rq.h.limiter.Reset(group)
rq.line.BanExpires = banExpires(ban)
if made {
rq.alertBan(ban)
}
return true
}
@@ -72,7 +88,7 @@ func (rq *request) limitBroken(now time.Time) bool {
// attack, the match of rule, a ban rule.
func (rq *request) banForAttack(now time.Time, rule rules.Rule) {
netblock := rq.h.netblock(rq.client)
ban := rq.h.ledger.BanForAttack(netblock, now, bans.Notes{
ban, made := rq.h.ledger.BanForAttack(netblock, now, bans.Notes{
Country: rq.line.Country,
RuleID: rule.ID,
Target: rule.Target,
@@ -80,6 +96,31 @@ func (rq *request) banForAttack(now time.Time, rule rules.Rule) {
Requests: rq.netblockRequests(netblock),
})
rq.line.BanExpires = banExpires(ban)
if made {
rq.alertBan(ban)
}
}
// alertBan raises the alert for ban, which the request made, or made
// permanent: permanent_ban for a permanent ban, ban for another. Its
// detail gives the ban's cause, when it ends, and its notes.
func (rq *request) alertBan(ban bans.Ban) {
event := alerts.EventBan
if ban.Permanent() {
event = alerts.EventPermanentBan
}
rq.h.alerts.Raise(alerts.Alert{
Event: event,
Client: rq.client,
Netblock: ban.Netblock,
Country: ban.Notes.Country,
Reason: ban.Reason,
Detail: map[string]any{
"cause": ban.Cause, "ban_expires": banExpires(ban), "notes": ban.Notes,
},
})
}
// noted is the request, refused at now with SWWAF_BAN_RESPONSE, as the