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 and worked out only when the alert would be sent; 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:
+90
-30
@@ -194,39 +194,43 @@ func (l *Ledger) Changed() <-chan struct{} {
|
||||
// a ban on a netblock client is in is active, and returns that ban, with
|
||||
// the request counted among those it refused. A ban for a clear sign of
|
||||
// attack is made permanent by the request: the netblock is malicious.
|
||||
func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool) {
|
||||
// The last result reports whether the request made the ban permanent.
|
||||
func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool, bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
ban := l.active(client, now)
|
||||
if ban == nil {
|
||||
return Ban{}, false
|
||||
return Ban{}, false, false
|
||||
}
|
||||
|
||||
ban.Notes.Requests++
|
||||
ban.Notes.Refused++
|
||||
|
||||
if ban.Cause == CauseAttack && !ban.Permanent() {
|
||||
madePermanent := ban.Cause == CauseAttack && !ban.Permanent()
|
||||
if madePermanent {
|
||||
ban.Expires = time.Time{}
|
||||
|
||||
l.markChanged()
|
||||
}
|
||||
|
||||
return *ban, true
|
||||
return *ban, true, madePermanent
|
||||
}
|
||||
|
||||
// Find is Check without counting the request among those the ban
|
||||
// refused: in observe mode a ban refuses nothing.
|
||||
func (l *Ledger) Find(client netip.Addr, now time.Time) (Ban, bool) {
|
||||
// refused, and without making the ban permanent: in observe mode a ban
|
||||
// refuses nothing. The last result reports whether Check would have made
|
||||
// the ban permanent.
|
||||
func (l *Ledger) Find(client netip.Addr, now time.Time) (Ban, bool, bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
ban := l.active(client, now)
|
||||
if ban == nil {
|
||||
return Ban{}, false
|
||||
return Ban{}, false, false
|
||||
}
|
||||
|
||||
return *ban, true
|
||||
return *ban, true, ban.Cause == CauseAttack && !ban.Permanent()
|
||||
}
|
||||
|
||||
// activeBan returns the ban in bans, a netblock's bans oldest first, that
|
||||
@@ -244,28 +248,78 @@ func activeBan(bans []Ban, now time.Time) *Ban {
|
||||
}
|
||||
|
||||
// BanForLimit bans netblock at now for a broken limit, with notes, and
|
||||
// returns the ban. A first ban lasts LimitBanDuration. A ban made within
|
||||
// LimitBanRepeatWindow after the netblock's ban that ended last, other
|
||||
// than one for a clear sign of attack or a lifted one, lasts repeatFactor
|
||||
// times as long as that one. A ban that would be longer than
|
||||
// MaxBanDuration is permanent instead. If a ban on netblock is still
|
||||
// returns the ban, and true. A first ban lasts LimitBanDuration. A ban
|
||||
// made within LimitBanRepeatWindow after the netblock's ban that ended
|
||||
// last, other than one for a clear sign of attack or a lifted one, lasts
|
||||
// repeatFactor times as long as that one. A ban that would be longer
|
||||
// than MaxBanDuration is permanent instead. If a ban on netblock is still
|
||||
// active, as when two of its requests break a limit at once, that ban is
|
||||
// returned and no other is made. The ledger fills in the notes' Refused
|
||||
// and EarlierBans itself, and gives the ban the reason "requests per
|
||||
// <Window> over the limit of <Limit>", from the notes.
|
||||
func (l *Ledger) BanForLimit(netblock netip.Prefix, now time.Time, notes Notes) Ban {
|
||||
reason := fmt.Sprintf("requests per %s over the limit of %d",
|
||||
notes.Window, notes.Limit)
|
||||
// returned with false, and no other is made. The ledger fills in the
|
||||
// notes' Refused and EarlierBans itself, and gives the ban the reason
|
||||
// "requests per <Window> over the limit of <Limit>", from the notes.
|
||||
func (l *Ledger) BanForLimit(
|
||||
netblock netip.Prefix, now time.Time, notes Notes,
|
||||
) (Ban, bool) {
|
||||
return l.ban(netblock, now, CauseLimit, limitReason(notes), notes, true)
|
||||
}
|
||||
|
||||
return l.ban(netblock, now, CauseLimit, reason, notes)
|
||||
// WouldBanForLimit returns what BanForLimit would, without making the ban:
|
||||
// what observe mode would have done.
|
||||
func (l *Ledger) WouldBanForLimit(
|
||||
netblock netip.Prefix, now time.Time, notes Notes,
|
||||
) (Ban, bool) {
|
||||
return l.ban(netblock, now, CauseLimit, limitReason(notes), notes, false)
|
||||
}
|
||||
|
||||
// BanForAttack bans netblock at now for a clear sign of attack, with
|
||||
// notes, and returns the ban, as BanForLimit does. A first ban lasts
|
||||
// AttackBanDuration; once the netblock has had one that was not lifted,
|
||||
// the next is permanent. Its reason is "matched the rule <RuleID>".
|
||||
func (l *Ledger) BanForAttack(netblock netip.Prefix, now time.Time, notes Notes) Ban {
|
||||
return l.ban(netblock, now, CauseAttack, "matched the rule "+notes.RuleID, notes)
|
||||
// notes, and returns the ban, and whether it made it, as BanForLimit
|
||||
// does. A first ban lasts AttackBanDuration; once the netblock has had
|
||||
// one that was not lifted, the next is permanent. Its reason is "matched
|
||||
// the rule <RuleID>".
|
||||
func (l *Ledger) BanForAttack(
|
||||
netblock netip.Prefix, now time.Time, notes Notes,
|
||||
) (Ban, bool) {
|
||||
return l.ban(netblock, now, CauseAttack, attackReason(notes), notes, true)
|
||||
}
|
||||
|
||||
// WouldBanForAttack returns what BanForAttack would, without making the
|
||||
// ban: what observe mode would have done.
|
||||
func (l *Ledger) WouldBanForAttack(
|
||||
netblock netip.Prefix, now time.Time, notes Notes,
|
||||
) (Ban, bool) {
|
||||
return l.ban(netblock, now, CauseAttack, attackReason(notes), notes, false)
|
||||
}
|
||||
|
||||
// WouldBePermanent reports whether a ban on netblock for cause, CauseLimit
|
||||
// or CauseAttack, made at now would be permanent, as BanForLimit or
|
||||
// BanForAttack would make it. It works out nothing else of the ban.
|
||||
func (l *Ledger) WouldBePermanent(
|
||||
netblock netip.Prefix, now time.Time, cause string,
|
||||
) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
var held []Ban
|
||||
if bans, found := l.netblocks.Peek(netblock); found {
|
||||
held = *bans
|
||||
}
|
||||
|
||||
if cause == CauseAttack {
|
||||
return l.attackExpiry(held, now).IsZero()
|
||||
}
|
||||
|
||||
return l.limitExpiry(held, now).IsZero()
|
||||
}
|
||||
|
||||
// limitReason is the reason of a ban for a broken limit, with notes.
|
||||
func limitReason(notes Notes) string {
|
||||
return fmt.Sprintf("requests per %s over the limit of %d", notes.Window, notes.Limit)
|
||||
}
|
||||
|
||||
// attackReason is the reason of a ban for a clear sign of attack, with
|
||||
// notes.
|
||||
func attackReason(notes Notes) string {
|
||||
return "matched the rule " + notes.RuleID
|
||||
}
|
||||
|
||||
// BanForAdmin bans netblock at now for an admin, with reason, until
|
||||
@@ -483,10 +537,12 @@ func (l *Ledger) holds(netblock netip.Prefix, start time.Time) bool {
|
||||
}
|
||||
|
||||
// ban bans netblock at now for cause, with reason and notes, as
|
||||
// BanForLimit and BanForAttack describe, and returns the ban.
|
||||
// BanForLimit and BanForAttack describe, and returns the ban, and whether
|
||||
// it made it. Unless keep is true, the ban is not made, only returned: it
|
||||
// is the ban that would have been made.
|
||||
func (l *Ledger) ban(
|
||||
netblock netip.Prefix, now time.Time, cause, reason string, notes Notes,
|
||||
) Ban {
|
||||
netblock netip.Prefix, now time.Time, cause, reason string, notes Notes, keep bool,
|
||||
) (Ban, bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
@@ -497,7 +553,7 @@ func (l *Ledger) ban(
|
||||
if found {
|
||||
active := activeBan(*bans, now)
|
||||
if active != nil {
|
||||
return *active
|
||||
return *active, false
|
||||
}
|
||||
|
||||
held = *bans
|
||||
@@ -513,11 +569,15 @@ func (l *Ledger) ban(
|
||||
ban.Expires = l.limitExpiry(held, now)
|
||||
}
|
||||
|
||||
if !keep {
|
||||
return ban, true
|
||||
}
|
||||
|
||||
l.add(ban)
|
||||
l.made[cause]++
|
||||
l.markChanged()
|
||||
|
||||
return ban
|
||||
return ban, true
|
||||
}
|
||||
|
||||
// earlierBans returns how many bans a netblock with the bans held, oldest
|
||||
|
||||
Reference in New Issue
Block a user