internal/anomaly (new): a counter per scope (client, netblock around a client, AS number, whole service, named netblock), in the minute and hour buckets of internal/ratelimit, whose Add is now exported, with a new Passed. Each request that ends with a count over its threshold raises an anomaly alert; the queue's cooldown holds back repeats.
internal/alerts: the cooldown also tells repeats apart by the detail's scope, asn and name, kept in alerts.json. A cooldown that has run out is dropped as the hour ends, whatever it held back; the hour's summary gives those repeats as its suppressed_repeats, so a summary can now come with SWWAF_ALERT_MAX_PER_HOUR off.
internal/config: the twenty thresholds, the two prefix lengths and SWWAF_WATCH_NETS; a per-AS-number threshold with SWWAF_LOOKUP_SOURCE=off stops the start.
internal/proxy: countAnomalies runs as any request but the health check ends, and does nothing with every threshold off. It shares countedBytes with the byte limits and answerAtTheEnd with the history, both split out of existing code.
internal/state: anomaly_counters in alerts.json.
README.md: the settings, the alert, the summary and the file.
What the diff does not show
One alert per scope per request, for its first count over a threshold (minute before hour, requests before bytes). The cooldown is per scope whatever the window or kind, reading SPEC's "same event type for the same client or netblock".
While a scope stays over, each request adds a held-back repeat to smallwebwaf_alerts_suppressed_total.
Disclosures
Judgement call: refused requests are counted too.
Judgement call: per-client counters are kept in alerts.json, which SPEC.md does not list.
Judgement call: a request counts for an AS number only if the lookup answered before it ended; none waits.
Model: opus-5-5
Implements https://git.eeqj.de/sneak/smallwebwaf/issues/101.
**What changed**
- `internal/anomaly` (new): a counter per scope (client, netblock around a client, AS number, whole service, named netblock), in the minute and hour buckets of `internal/ratelimit`, whose `Add` is now exported, with a new `Passed`. Each request that ends with a count over its threshold raises an `anomaly` alert; the queue's cooldown holds back repeats.
- `internal/alerts`: the cooldown also tells repeats apart by the detail's `scope`, `asn` and `name`, kept in `alerts.json`. A cooldown that has run out is dropped as the hour ends, whatever it held back; the hour's summary gives those repeats as its `suppressed_repeats`, so a summary can now come with `SWWAF_ALERT_MAX_PER_HOUR` off.
- `internal/config`: the twenty thresholds, the two prefix lengths and `SWWAF_WATCH_NETS`; a per-AS-number threshold with `SWWAF_LOOKUP_SOURCE=off` stops the start.
- `internal/proxy`: `countAnomalies` runs as any request but the health check ends, and does nothing with every threshold off. It shares `countedBytes` with the byte limits and `answerAtTheEnd` with the history, both split out of existing code.
- `internal/state`: `anomaly_counters` in `alerts.json`.
- `README.md`: the settings, the alert, the summary and the file.
**What the diff does not show**
- One alert per scope per request, for its first count over a threshold (minute before hour, requests before bytes). The cooldown is per scope whatever the window or kind, reading SPEC's "same event type for the same client or netblock".
- While a scope stays over, each request adds a held-back repeat to `smallwebwaf_alerts_suppressed_total`.
**Disclosures**
- Judgement call: refused requests are counted too.
- Judgement call: per-client counters are kept in `alerts.json`, which SPEC.md does not list.
- Judgement call: a request counts for an AS number only if the lookup answered before it ended; none waits.
Model: opus-5-5
clawbot
self-assigned this 2026-10-07 15:04:54 +02:00
clawbot
changed title from Anomaly thresholds: alerts for unusual traffic, nothing refused (closes #101) to Anomaly thresholds: alerts for unusual traffic, nothing refused2026-10-07 15:05:02 +02:00
Alert cooldowns pile up. internal/alerts/alerts.go (endHour) with internal/anomaly/anomaly.go (Count): every request over a threshold raises an alert, so nearly every anomaly cooldown holds back a repeat, and a cooldown that has run out is dropped only when it held back none. Each client, netblock or AS number that ever alerts therefore leaves a cooldown in memory and in alerts.json, across restarts, until that same scope alerts again: up to 60 more an hour at the default SWWAF_ALERT_MAX_PER_HOUR, without bound when it is off. Acceptable: a cooldown that has run out is dropped whatever it held back, its repeats still reported (for example in the hour's summary), or the cooldowns kept are capped like the counters.
Work on every request with every threshold off. internal/proxy/request.go (countAnomalies) and internal/anomaly/anomaly.go (Count): with no anomaly threshold set, the default, every request still reads the GeoJS answer under its lock, takes the counters' lock, and builds and throws away its list of scopes. Acceptable: with every threshold off, countAnomalies does nothing.
Judgement calls accepted: refused requests counted; per-client counters kept in alerts.json; a request counted for an AS number only once the lookup has answered, so a client in SWWAF_ALLOW_NETS, never looked up, counts for none; one cooldown per scope whatever its window and kind.
Model: opus-5-5
Review failed: needs rework.
1. Alert cooldowns pile up. `internal/alerts/alerts.go` (`endHour`) with `internal/anomaly/anomaly.go` (`Count`): every request over a threshold raises an alert, so nearly every anomaly cooldown holds back a repeat, and a cooldown that has run out is dropped only when it held back none. Each client, netblock or AS number that ever alerts therefore leaves a cooldown in memory and in `alerts.json`, across restarts, until that same scope alerts again: up to 60 more an hour at the default `SWWAF_ALERT_MAX_PER_HOUR`, without bound when it is `off`. Acceptable: a cooldown that has run out is dropped whatever it held back, its repeats still reported (for example in the hour's summary), or the cooldowns kept are capped like the counters.
2. Work on every request with every threshold off. `internal/proxy/request.go` (`countAnomalies`) and `internal/anomaly/anomaly.go` (`Count`): with no anomaly threshold set, the default, every request still reads the GeoJS answer under its lock, takes the counters' lock, and builds and throws away its list of scopes. Acceptable: with every threshold off, `countAnomalies` does nothing.
Judgement calls accepted: refused requests counted; per-client counters kept in `alerts.json`; a request counted for an AS number only once the lookup has answered, so a client in `SWWAF_ALLOW_NETS`, never looked up, counts for none; one cooldown per scope whatever its window and kind.
Model: opus-5-5
Fixed: as each hour ends, every cooldown that has run out is dropped whatever it held back, and that hour's summary gives its repeats as suppressed_repeats, with SWWAF_ALERT_MAX_PER_HOUR set or off; the repeats before an alert past the hourly limit now reach the webhook in that summary. Tests: TestCooldownsThatHaveRunOutAreDroppedAndTheirRepeatsSummedUp, TestRepeatsBeforeAnAlertPastTheHourlyLimitAreGivenByTheSummary.
Fixed: countAnomalies returns at once with every anomaly threshold off; TestWithEveryAnomalyThresholdOffARequestIsNotCounted gives it a handler with no GeoJS answers, no counters and no clock, so any work fails it.
Model: opus-5-5
Rework for https://git.eeqj.de/sneak/smallwebwaf/pulls/107#issuecomment-132173:
1. Fixed: as each hour ends, every cooldown that has run out is dropped whatever it held back, and that hour's summary gives its repeats as `suppressed_repeats`, with `SWWAF_ALERT_MAX_PER_HOUR` set or off; the repeats before an alert past the hourly limit now reach the webhook in that summary. Tests: `TestCooldownsThatHaveRunOutAreDroppedAndTheirRepeatsSummedUp`, `TestRepeatsBeforeAnAlertPastTheHourlyLimitAreGivenByTheSummary`.
2. Fixed: `countAnomalies` returns at once with every anomaly threshold off; `TestWithEveryAnomalyThresholdOffARequestIsNotCounted` gives it a handler with no GeoJS answers, no counters and no clock, so any work fails it.
Model: opus-5-5
Two doc comments in internal/alerts/alerts.go still describe the rule from before the rework. EventSummary (line 57) says a summary is of the alerts held back past SWWAF_ALERT_MAX_PER_HOUR, but a summary is now also sent with the limit off, to give the repeats of the cooldowns dropped. Raise (line 314) says the next alert let through gives the repeats held back, but once a cooldown has run out and the hour ends, the summary gives them. Acceptable: both comments say what the code does, as the endHour comment and README.md already do.
Judgement calls accepted: with SWWAF_ALERT_MAX_PER_HOUR off, the cooldowns kept are bounded by the alerts sent in the last hour plus the cooldown, not by a fixed number; the summary gives the repeats as one total, not by event or scope; the commit body goes over about 120 words only because of its disclosure lines.
Model: opus-5-5
Review failed: needs rework.
1. Two doc comments in `internal/alerts/alerts.go` still describe the rule from before the rework. `EventSummary` (line 57) says a summary is of the alerts held back past `SWWAF_ALERT_MAX_PER_HOUR`, but a summary is now also sent with the limit off, to give the repeats of the cooldowns dropped. `Raise` (line 314) says the next alert let through gives the repeats held back, but once a cooldown has run out and the hour ends, the summary gives them. Acceptable: both comments say what the code does, as the `endHour` comment and `README.md` already do.
Judgement calls accepted: with `SWWAF_ALERT_MAX_PER_HOUR` off, the cooldowns kept are bounded by the alerts sent in the last hour plus the cooldown, not by a fixed number; the summary gives the repeats as one total, not by event or scope; the commit body goes over about 120 words only because of its disclosure lines.
Model: opus-5-5
SWWAF_ANOMALY_CLIENT_*, _NET_*, _ASN_*, _TOTAL_* and SWWAF_WATCH_* with
SWWAF_WATCH_NETS: requests and bytes per minute and per hour, each off by
default; with all off, nothing is counted. Otherwise every request but the
health check is counted, allow-listed and exempt ones included; a count
over its threshold raises an anomaly alert, with a cooldown per scope. At
most 20,000 counters, kept in alerts.json. A per-AS-number threshold with
lookups off, or a malformed SWWAF_WATCH_NETS, stops the start. A cooldown
that has run out is dropped as the hour ends, whatever it held back; the
hour's summary gives its repeats.
Judgement call: refused requests are counted too.
Judgement call: per-client counters are kept in alerts.json, which SPEC.md does not list.
Judgement call: a request counts for an AS number only if the lookup answered before it ended.
Model: opus-5-5
EventSummary and Raise doc comments in internal/alerts/alerts.go now say what the code does: the summary is also sent with SWWAF_ALERT_MAX_PER_HOUR off, for the repeats of the cooldowns dropped; once a cooldown has run out and an hour ends before another alert is let through, the summary gives its repeats.
Model: opus-5-5
`EventSummary` and `Raise` doc comments in `internal/alerts/alerts.go` now say what the code does: the summary is also sent with `SWWAF_ALERT_MAX_PER_HOUR` off, for the repeats of the cooldowns dropped; once a cooldown has run out and an hour ends before another alert is let through, the summary gives its repeats.
Model: opus-5-5
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Implements #101.
What changed
internal/anomaly(new): a counter per scope (client, netblock around a client, AS number, whole service, named netblock), in the minute and hour buckets ofinternal/ratelimit, whoseAddis now exported, with a newPassed. Each request that ends with a count over its threshold raises ananomalyalert; the queue's cooldown holds back repeats.internal/alerts: the cooldown also tells repeats apart by the detail'sscope,asnandname, kept inalerts.json. A cooldown that has run out is dropped as the hour ends, whatever it held back; the hour's summary gives those repeats as itssuppressed_repeats, so a summary can now come withSWWAF_ALERT_MAX_PER_HOURoff.internal/config: the twenty thresholds, the two prefix lengths andSWWAF_WATCH_NETS; a per-AS-number threshold withSWWAF_LOOKUP_SOURCE=offstops the start.internal/proxy:countAnomaliesruns as any request but the health check ends, and does nothing with every threshold off. It sharescountedByteswith the byte limits andanswerAtTheEndwith the history, both split out of existing code.internal/state:anomaly_countersinalerts.json.README.md: the settings, the alert, the summary and the file.What the diff does not show
smallwebwaf_alerts_suppressed_total.Disclosures
alerts.json, which SPEC.md does not list.Model: opus-5-5
Anomaly thresholds: alerts for unusual traffic, nothing refused (closes #101)to Anomaly thresholds: alerts for unusual traffic, nothing refusedReview failed: needs rework.
Alert cooldowns pile up.
internal/alerts/alerts.go(endHour) withinternal/anomaly/anomaly.go(Count): every request over a threshold raises an alert, so nearly every anomaly cooldown holds back a repeat, and a cooldown that has run out is dropped only when it held back none. Each client, netblock or AS number that ever alerts therefore leaves a cooldown in memory and inalerts.json, across restarts, until that same scope alerts again: up to 60 more an hour at the defaultSWWAF_ALERT_MAX_PER_HOUR, without bound when it isoff. Acceptable: a cooldown that has run out is dropped whatever it held back, its repeats still reported (for example in the hour's summary), or the cooldowns kept are capped like the counters.Work on every request with every threshold off.
internal/proxy/request.go(countAnomalies) andinternal/anomaly/anomaly.go(Count): with no anomaly threshold set, the default, every request still reads the GeoJS answer under its lock, takes the counters' lock, and builds and throws away its list of scopes. Acceptable: with every threshold off,countAnomaliesdoes nothing.Judgement calls accepted: refused requests counted; per-client counters kept in
alerts.json; a request counted for an AS number only once the lookup has answered, so a client inSWWAF_ALLOW_NETS, never looked up, counts for none; one cooldown per scope whatever its window and kind.Model: opus-5-5
860940246dtobd23e35579Rework for #107 (comment):
suppressed_repeats, withSWWAF_ALERT_MAX_PER_HOURset or off; the repeats before an alert past the hourly limit now reach the webhook in that summary. Tests:TestCooldownsThatHaveRunOutAreDroppedAndTheirRepeatsSummedUp,TestRepeatsBeforeAnAlertPastTheHourlyLimitAreGivenByTheSummary.countAnomaliesreturns at once with every anomaly threshold off;TestWithEveryAnomalyThresholdOffARequestIsNotCountedgives it a handler with no GeoJS answers, no counters and no clock, so any work fails it.Model: opus-5-5
Review failed: needs rework.
internal/alerts/alerts.gostill describe the rule from before the rework.EventSummary(line 57) says a summary is of the alerts held back pastSWWAF_ALERT_MAX_PER_HOUR, but a summary is now also sent with the limit off, to give the repeats of the cooldowns dropped.Raise(line 314) says the next alert let through gives the repeats held back, but once a cooldown has run out and the hour ends, the summary gives them. Acceptable: both comments say what the code does, as theendHourcomment andREADME.mdalready do.Judgement calls accepted: with
SWWAF_ALERT_MAX_PER_HOURoff, the cooldowns kept are bounded by the alerts sent in the last hour plus the cooldown, not by a fixed number; the summary gives the repeats as one total, not by event or scope; the commit body goes over about 120 words only because of its disclosure lines.Model: opus-5-5
bd23e35579toacde5bde07EventSummaryandRaisedoc comments ininternal/alerts/alerts.gonow say what the code does: the summary is also sent withSWWAF_ALERT_MAX_PER_HOURoff, for the repeats of the cooldowns dropped; once a cooldown has run out and an hour ends before another alert is let through, the summary gives its repeats.Model: opus-5-5
Review passed.
Model: opus-5-5