check / check (push) Waiting to run
SPEC.md is the only place that says how smallwebwaf behaves and why; docs/ holds lists linking to it; the README is short and links to both. Passed independent review in round 8. Model: fable-5-1 (writing); opus-5-5 (review, merge)
134 lines
5.0 KiB
Markdown
134 lines
5.0 KiB
Markdown
# Alerts
|
|
|
|
The alert events, the JSON object the webhook gets, and the messages Slack and
|
|
ntfy get. "Alerting" in [`SPEC.md`](../SPEC.md) says when an alert is sent, held
|
|
back or dropped.
|
|
|
|
## Events
|
|
|
|
- `ban`: a ban `smallwebwaf` made.
|
|
- `permanent_ban`: a permanent ban it made, or an existing ban made permanent by
|
|
a request.
|
|
- `waf_block`: the Core Rule Set scored a request at or over the threshold. The
|
|
request was refused unless the alert's `detail` gives `mode`: `detect` or
|
|
`observe`.
|
|
- `anomaly`: a count over an anomaly threshold.
|
|
- `reputation_hit`: a request from a client on a blocklist, the CrowdSec
|
|
decision list or a DNS blocklist, or with an AbuseIPDB score that is a hit.
|
|
One alert per source.
|
|
- `source_failure`: one of:
|
|
- GeoJS failing;
|
|
- a list that cannot be fetched;
|
|
- a DNS blocklist or AbuseIPDB failing or refusing a query;
|
|
- the day's AbuseIPDB checks used up.
|
|
- `file_error`: one of:
|
|
- a rule file or state file edit with an error;
|
|
- a state file that cannot be written;
|
|
- a replacement lookup database that cannot be read.
|
|
- `summary`: the hourly summary of alerts held back.
|
|
|
|
## The JSON object
|
|
|
|
This is the alert for a ban for a broken rate limit, shown indented; it is sent
|
|
on one line:
|
|
|
|
```json
|
|
{
|
|
"instance": "fsn1app1/gitea",
|
|
"time": "2026-10-06T12:00:00.123461Z",
|
|
"event": "ban",
|
|
"client": "203.0.113.9",
|
|
"netblock": "203.0.113.9/32",
|
|
"asn": "",
|
|
"as_name": "",
|
|
"country": "",
|
|
"reason": "requests per minute over the limit of 1000",
|
|
"detail": {
|
|
"ban_expires": "2026-10-06T13:00:00.123Z",
|
|
"cause": "limit",
|
|
"notes": {
|
|
"asn": "",
|
|
"as_name": "",
|
|
"country": "",
|
|
"kind": "requests",
|
|
"limit": 1000,
|
|
"window": "minute",
|
|
"count": 1001,
|
|
"request": {
|
|
"time": "2026-10-06T12:00:00.123456789Z",
|
|
"method": "GET",
|
|
"host": "app.example",
|
|
"path": "/owner/repo/commits/branch/main?page=812",
|
|
"status": 403,
|
|
"user_agent": "scraper/1.0"
|
|
},
|
|
"requests": 5210,
|
|
"refused": 0,
|
|
"earlier_bans": {
|
|
"limit": 0,
|
|
"attack": 0,
|
|
"admin": 0,
|
|
"crowdsec": 0
|
|
}
|
|
}
|
|
},
|
|
"suppressed_repeats": 0
|
|
}
|
|
```
|
|
|
|
- `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
|
|
one of the events above.
|
|
- `client`: the address whose request raised the alert. Empty for
|
|
`source_failure`, `file_error` and `summary`.
|
|
- `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
|
|
one of:
|
|
- the ban's netblock;
|
|
- the netblock an anomaly counted, empty in the `asn` and `total` scopes;
|
|
- for the other events, the client as rate limiting counts it: an IPv4
|
|
address as a /32, or an IPv6 group.
|
|
- `asn`, `as_name` and `country`: the client's, as known when the alert was
|
|
raised. They are empty when GeoJS had not answered yet.
|
|
- `reason`: a short sentence, such as
|
|
`requests per minute over the limit of 1000` or `listed by a blocklist`.
|
|
- `detail`: what is particular to the event:
|
|
- for a ban, its `cause`, `ban_expires` and `notes` (the notes are listed
|
|
under `bans.json` in [`state-files.md`](state-files.md));
|
|
- for an anomaly, the `scope`, `window`, `kind`, `count` and `threshold`;
|
|
- for a reputation hit, the `source` and, for AbuseIPDB, the `score`;
|
|
- for a Core Rule Set match, the `rule_ids`, `score`, `method` and `path`;
|
|
- for a source failure, the `source` and `error`;
|
|
- for a file error, the `file` and `error`;
|
|
- for a summary, the `hour` it covers, the `count` of alerts held back, and
|
|
that count by event in `events`;
|
|
- `mode`, when the request was not refused: `detect` or `observe`.
|
|
- `suppressed_repeats`: how many repeats the cooldown held back before this
|
|
alert.
|
|
|
|
## Slack and ntfy
|
|
|
|
Slack and ntfy get a message with a title and a text. The title is the instance
|
|
and the event, such as `fsn1app1/gitea: ban`. The text is the `reason`, then one
|
|
line for each of these that the alert has:
|
|
|
|
- the client, the netblock and the country;
|
|
- the file, the source, the error and the mode from `detail`;
|
|
- the suppressed repeats, if any.
|
|
|
|
Slack gets the title in bold and the text below it, with `&`, `<` and `>`
|
|
escaped. ntfy gets the text as the message, with these headers:
|
|
|
|
- `Title`: the title;
|
|
- `Authorization`: the token, as `Bearer <token>`, while
|
|
`SWWAF_ALERT_NTFY_TOKEN` is set;
|
|
- `Priority` and `Tags`: by event, as listed below.
|
|
|
|
The priority and tag by event:
|
|
|
|
- `ban`: priority `default`, tag `no_entry`.
|
|
- `permanent_ban`: priority `high`, tag `no_entry`.
|
|
- `waf_block`: priority `default`, tag `shield`.
|
|
- `anomaly`: priority `high`, tag `chart_with_upwards_trend`.
|
|
- `reputation_hit`: priority `low`, tag `label`.
|
|
- `source_failure` and `file_error`: priority `high`, tag `warning`.
|
|
- `summary`: priority `default`, tag `bar_chart`.
|