Anomaly thresholds: alerts for unusual traffic, nothing refused (closes #101)
check / check (push) Waiting to run
check / check (push) Waiting to run
Work in progress. Model: opus-5-5
This commit is contained in:
+161
-11
@@ -22,6 +22,7 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||
@@ -156,7 +157,50 @@ const filledAlertsJSON = `{
|
||||
"suppressed_repeats": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"anomaly_counters": [
|
||||
{
|
||||
"scope": "asn",
|
||||
"asn": "AS64496",
|
||||
"hour_bytes": {
|
||||
"start": "2026-10-06T00:00:00Z",
|
||||
"current": 8,
|
||||
"previous": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "net",
|
||||
"netblock": "203.0.113.0/24",
|
||||
"minute": {
|
||||
"start": "2026-10-06T00:00:00Z",
|
||||
"current": 1,
|
||||
"previous": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "total",
|
||||
"minute": {
|
||||
"start": "2026-10-06T00:00:00Z",
|
||||
"current": 1,
|
||||
"previous": 0
|
||||
},
|
||||
"minute_bytes": {
|
||||
"start": "2026-10-06T00:00:00Z",
|
||||
"current": 8,
|
||||
"previous": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": "watch",
|
||||
"netblock": "203.0.113.0/24",
|
||||
"name": "office",
|
||||
"hour": {
|
||||
"start": "2026-10-06T00:00:00Z",
|
||||
"current": 1,
|
||||
"previous": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
`
|
||||
|
||||
@@ -191,6 +235,8 @@ func TestFilesWrittenAndReadBack(t *testing.T) {
|
||||
t.Errorf("%s read back\n%+v\nwant\n%+v", alertsJSON, got, want)
|
||||
}
|
||||
|
||||
wantEqual(t, alertsJSON, after.Anomalies.Snapshot(), before.Anomalies.Snapshot())
|
||||
|
||||
// Each one-per-line file lists its entries by client, and nothing
|
||||
// but the four files is left in the directory.
|
||||
wantEntries(t, filepath.Join(dir, clientsJSON), "clients",
|
||||
@@ -251,6 +297,49 @@ func TestSourceFailureCooldownKeptInAlertsJSONAcrossARestart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnomalyCountersKeptInAlertsJSONAcrossARestart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
request := anomaly.Request{
|
||||
Client: netip.MustParseAddr("203.0.113.9"),
|
||||
ClientGroup: netip.MustParsePrefix("203.0.113.9/32"),
|
||||
}
|
||||
|
||||
// The whole service may have two requests a minute.
|
||||
withThreshold := func() state.Params {
|
||||
params := newParams(dir)
|
||||
params.Anomalies = anomaly.New(anomaly.Params{
|
||||
Total: anomaly.Thresholds{RequestsPerMinute: 2}, Alerts: params.Alerts,
|
||||
})
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
before := withThreshold()
|
||||
files := load(t, before)
|
||||
|
||||
for range 2 {
|
||||
before.Anomalies.Count(midnight(), request)
|
||||
}
|
||||
|
||||
err := files.WriteAll()
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
// After the restart, the third request in the minute is over it.
|
||||
after := withThreshold()
|
||||
load(t, after)
|
||||
after.Anomalies.Count(midnight(), request)
|
||||
|
||||
waiting := after.Alerts.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||
if len(waiting) != 1 || waiting[0].Event != alerts.EventAnomaly ||
|
||||
waiting[0].Detail["count"] != float64(3) {
|
||||
t.Errorf("alerts wait %+v, want one for 3 requests", waiting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBansJSONIsIndentedWithNullForAPermanentBan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -332,6 +421,13 @@ func TestFileThatDoesNotParseStopsTheStart(t *testing.T) {
|
||||
`{"version": 1, "waiting": {"webhook": [], "slak": []}}`,
|
||||
`: waiting "slak" is not webhook, slack or ntfy`,
|
||||
},
|
||||
{
|
||||
"an anomaly counter of an unknown scope", alertsJSON,
|
||||
`{"version": 1, "anomaly_counters": [{"scope": "total"}, ` +
|
||||
`{"scope": "nett", "netblock": "203.0.113.0/24"}]}`,
|
||||
`: anomaly_counters entry 2's scope "nett" is not client, net, asn, total ` +
|
||||
`or watch`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -455,6 +551,39 @@ func TestAlertsJSONEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
|
||||
`{"version": 1, "waiting": {"slack": [{"event": "ban"}]}}`,
|
||||
`: waiting slack entry 1 has no "time"`,
|
||||
},
|
||||
{
|
||||
// The whole service's counter needs nothing to tell it apart.
|
||||
"an anomaly counter of a netblock without it",
|
||||
`{"version": 1, "anomaly_counters": [{"scope": "total"}, {"scope": "net"}]}`,
|
||||
`: anomaly_counters entry 2 has no "netblock"`,
|
||||
},
|
||||
{
|
||||
"an anomaly counter of a client without its netblock",
|
||||
`{"version": 1, "anomaly_counters": [{"scope": "client"}]}`,
|
||||
`: anomaly_counters entry 1 has no "netblock"`,
|
||||
},
|
||||
{
|
||||
"an anomaly counter of an AS number without it",
|
||||
`{"version": 1, "anomaly_counters": [{"scope": "asn"}]}`,
|
||||
`: anomaly_counters entry 1 has no "asn"`,
|
||||
},
|
||||
{
|
||||
"an anomaly counter of a named netblock without its name",
|
||||
`{"version": 1, "anomaly_counters": [` +
|
||||
`{"scope": "watch", "netblock": "203.0.113.0/24"}]}`,
|
||||
`: anomaly_counters entry 1 has no "name"`,
|
||||
},
|
||||
{
|
||||
"an anomaly counter of a named netblock without its netblock",
|
||||
`{"version": 1, "anomaly_counters": [{"scope": "watch", "name": "office"}]}`,
|
||||
`: anomaly_counters entry 1 has no "netblock"`,
|
||||
},
|
||||
{
|
||||
"an anomaly counter with bytes in a window without its start",
|
||||
`{"version": 1, "anomaly_counters": [` +
|
||||
`{"scope": "total", "hour_bytes": {"current": 5}}]}`,
|
||||
`: anomaly_counters entry 1 has no "hour_bytes.start"`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -1273,10 +1402,19 @@ func midnight() time.Time {
|
||||
|
||||
// newParams returns Params for the state files in dir, with parts that
|
||||
// hold nothing yet. GeoJS is never asked, and the alerts, at most two an
|
||||
// hour, are never sent.
|
||||
// hour, are never sent. The anomaly counters count the scopes fill
|
||||
// counts, with thresholds fill does not reach.
|
||||
func newParams(dir string) state.Params {
|
||||
discard := slog.New(slog.DiscardHandler)
|
||||
m := metrics.New(1, "app")
|
||||
queue := alerts.New(alerts.Params{
|
||||
WebhookURL: &url.URL{Scheme: "https", Host: "alerts.example"},
|
||||
Events: alerts.Events(),
|
||||
Cooldown: 15 * time.Minute,
|
||||
MaxPerHour: 2,
|
||||
Instance: "fsn1app1/gitea",
|
||||
Now: midnight,
|
||||
})
|
||||
|
||||
return state.Params{
|
||||
Dir: dir,
|
||||
@@ -1293,13 +1431,16 @@ func newParams(dir string) state.Params {
|
||||
GeoJS: lookup.New(lookup.Params{
|
||||
Now: midnight, ProcessLog: discard, Metrics: m,
|
||||
}),
|
||||
Alerts: alerts.New(alerts.Params{
|
||||
WebhookURL: &url.URL{Scheme: "https", Host: "alerts.example"},
|
||||
Events: alerts.Events(),
|
||||
Cooldown: 15 * time.Minute,
|
||||
MaxPerHour: 2,
|
||||
Instance: "fsn1app1/gitea",
|
||||
Now: midnight,
|
||||
Alerts: queue,
|
||||
Anomalies: anomaly.New(anomaly.Params{
|
||||
Net: anomaly.Thresholds{RequestsPerMinute: 1000},
|
||||
ASN: anomaly.Thresholds{BytesPerHour: 1 << 30},
|
||||
Total: anomaly.Thresholds{RequestsPerMinute: 1000, BytesPerMinute: 1 << 30},
|
||||
Watch: anomaly.Thresholds{RequestsPerHour: 1000},
|
||||
NetV4Prefix: 24,
|
||||
NetV6Prefix: 48,
|
||||
NamedNetblocks: []anomaly.NamedNetblock{{Name: "office", Netblock: office()}},
|
||||
Alerts: queue,
|
||||
}),
|
||||
Now: midnight,
|
||||
ProcessLog: discard,
|
||||
@@ -1307,10 +1448,15 @@ func newParams(dir string) state.Params {
|
||||
}
|
||||
}
|
||||
|
||||
// office is the named netblock of the anomaly counters of newParams.
|
||||
func office() netip.Prefix {
|
||||
return netip.MustParsePrefix("203.0.113.0/24")
|
||||
}
|
||||
|
||||
// fill puts a permanent ban an admin made, a ban for a broken limit and
|
||||
// one for a clear sign of attack, clients with counts and histories,
|
||||
// GeoJS answers, and alerts, as filledAlertsJSON holds them, into the
|
||||
// parts of params.
|
||||
// GeoJS answers, and alerts and anomaly counters, as filledAlertsJSON
|
||||
// holds them, into the parts of params.
|
||||
func fill(params state.Params) {
|
||||
now := midnight()
|
||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||
@@ -1361,6 +1507,10 @@ func fill(params state.Params) {
|
||||
params.Alerts.Raise(alerts.Alert{
|
||||
Event: alerts.EventSourceFailure, Reason: "asking GeoJS failed",
|
||||
})
|
||||
|
||||
params.Anomalies.Count(now, anomaly.Request{
|
||||
Client: client.Addr(), ClientGroup: client, ASN: asn, Bytes: 8,
|
||||
})
|
||||
}
|
||||
|
||||
// permanentBan is the ban permanentBansJSON holds.
|
||||
|
||||
Reference in New Issue
Block a user