AbuseIPDB scores for clients that committed an offence, within a daily budget (closes #105)
check / check (push) Waiting to run

With SWWAF_ABUSEIPDB_KEY set, a client whose history counts an offence
(a broken limit, a ban rule's match or a block rule's refusal, counted
by kind) is checked in the background, at most
SWWAF_ABUSEIPDB_DAILY_BUDGET checks a day, the count kept in
reputation.json. A client, an IPv4 address or an IPv6 /64, is checked by
the address it sent from, and its score serves all its addresses. A
score at or over SWWAF_ABUSEIPDB_MIN_SCORE is a hit for
SWWAF_REPUTATION_ACTION, logged as abuseipdb and alerted with its score.
A failure or the used-up budget gives no score and raises
source_failure. The key goes only in the Key header.

Judgement call: the budget's day is UTC; AbuseIPDB documents no reset time.
Judgement call: each check sent spends budget; a minute's pause after a failure.

Model: opus-5-5
This commit is contained in:
2026-10-07 20:22:37 +00:00
parent 0b0f207423
commit 2672fc3b34
23 changed files with 2040 additions and 309 deletions
+244 -147
View File
@@ -26,36 +26,39 @@ Slack and ntfy, and remote log sending. So is the stage after that: the AS
number and country of every client, looked up through GeoJS or in the IPinfo
Lite database file, the byte limits, the biased thresholds, lower limits for the
AS numbers and countries you list, and the anomaly thresholds, alerts for
unusual traffic that refuse nothing. So are the first two parts of the stage
unusual traffic that refuse nothing. So are the first three parts of the stage
after that: the blocklists you name by URL, which it fetches and keeps, with a
file of AS numbers' percentages fetched the same way, and the DNS blocklists
(DNSBL zones), which it asks about each client in the background. `smallwebwaf`
passes each request to the app and the app's answer back, unchanged, within its
timeouts and size limits, works out each client's address, looks up its AS
number and country unless you switch that off, bans a client that sends too many
requests or too many bytes, not counting those for the paths you choose, with
lower limits for the clients of the AS numbers and countries you list, refuses a
client that comes from a country you refuse or from a network you refuse,
refuses, limits or only notes a client a blocklist or a DNSBL zone you name
lists, lets the networks you choose through, checks each request against the
rule files and bans a client whose request is a clear sign of attack, keeps its
bans, each client's counters and history, GeoJS's answers, the last good copy of
each list it fetches and the DNSBL zones' verdicts in JSON files across
restarts, takes in your edits of those files, such as a ban you make, keep or
lift, and of the rule files while it runs, writes a JSON log line for every
request, sends its log lines to a syslog server too if you name one, sends an
alert to a webhook, to Slack and to ntfy, each if you name one, for each ban it
makes or makes permanent, for traffic over an anomaly threshold you set, for a
client a blocklist or a DNSBL zone lists, for GeoJS failing, a list it cannot
fetch or a DNSBL zone that fails or refuses a query, for a rule file or state
file with an error and for a replacement of the lookup database it cannot read,
serves Prometheus metrics to a scraper that holds the metrics token, lets an
admin who holds the admin token list, add and lift bans and ask what it knows of
a client, and in `observe` mode passes on the requests it would refuse, logging
what it would have done with them. It comes as the image the app's own image is
built on. The rest of the design comes after that, in the order of the build
order in [`SPEC.md`](SPEC.md). The survey of existing tools that led to the
design is in [`EVALUATION.md`](EVALUATION.md).
file of AS numbers' percentages fetched the same way, the DNS blocklists (DNSBL
zones), which it asks about each client in the background, and AbuseIPDB, which
it asks in the background about each client that has committed an offence.
`smallwebwaf` passes each request to the app and the app's answer back,
unchanged, within its timeouts and size limits, works out each client's address,
looks up its AS number and country unless you switch that off, bans a client
that sends too many requests or too many bytes, not counting those for the paths
you choose, with lower limits for the clients of the AS numbers and countries
you list, refuses a client that comes from a country you refuse or from a
network you refuse, refuses, limits or only notes a client a blocklist or a
DNSBL zone you name lists, or AbuseIPDB scores at or over the score you set,
lets the networks you choose through, checks each request against the rule files
and bans a client whose request is a clear sign of attack, keeps its bans, each
client's counters and history, GeoJS's answers, the last good copy of each list
it fetches, the DNSBL zones' verdicts, AbuseIPDB's scores and the AbuseIPDB
checks spent today in JSON files across restarts, takes in your edits of those
files, such as a ban you make, keep or lift, and of the rule files while it
runs, writes a JSON log line for every request, sends its log lines to a syslog
server too if you name one, sends an alert to a webhook, to Slack and to ntfy,
each if you name one, for each ban it makes or makes permanent, for traffic over
an anomaly threshold you set, for a client a blocklist, a DNSBL zone or
AbuseIPDB lists, for GeoJS failing, a list it cannot fetch, a DNSBL zone or
AbuseIPDB that fails or refuses a query and the day's AbuseIPDB checks used up,
for a rule file or state file with an error and for a replacement of the lookup
database it cannot read, serves Prometheus metrics to a scraper that holds the
metrics token, lets an admin who holds the admin token list, add and lift bans
and ask what it knows of a client, and in `observe` mode passes on the requests
it would refuse, logging what it would have done with them. It comes as the
image the app's own image is built on. The rest of the design comes after that,
in the order of the build order in [`SPEC.md`](SPEC.md). The survey of existing
tools that led to the design is in [`EVALUATION.md`](EVALUATION.md).
## Getting started
@@ -222,35 +225,46 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
make no ban. With `log`, nothing more is done. Whatever the action, the
request's log line names the zones, and each raises an alert. A client a
blocklist refuses is not checked.
- Checks the client with AbuseIPDB while `SWWAF_ABUSEIPDB_KEY` is set, after the
DNSBL zones and before the rate limits (see "AbuseIPDB" below), by the scores
it keeps. Only a client whose history counts an offence is checked, so far one
that has broken a rate limit or a byte limit, matched a ban rule, or had a
request refused by a block rule, and only in the background, so that no
request waits for AbuseIPDB. A score at or over `SWWAF_ABUSEIPDB_MIN_SCORE` is
a hit, and `SWWAF_REPUTATION_ACTION` does with its client what it does with
one a DNSBL zone's verdict lists. The request's log line names AbuseIPDB, and
it raises an alert. A client a blocklist or a DNSBL zone refuses is not
checked.
- Checks the client's own address against the static lists, the three netblock
settings below, before anything else, its lookup included. A client in
`SWWAF_ALLOW_NETS` skips bans, the country lists, the blocklists, the DNSBL
zones, the rate limits, the byte limits and the rule files, and is not looked
up; the timeouts and size limits still apply. A client in `SWWAF_DENY_NETS` is
refused with `SWWAF_BAN_RESPONSE` before its body is read, and the request is
not counted for the rate limits; an address in `SWWAF_ALLOW_NETS` too is let
through. A client in `SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither counted nor
refused by the rate limits, and has no bytes counted by the byte limits; the
country lists, the rule files and bans still apply to it.
zones, AbuseIPDB, the rate limits, the byte limits and the rule files, and is
not looked up; the timeouts and size limits still apply. A client in
`SWWAF_DENY_NETS` is refused with `SWWAF_BAN_RESPONSE` before its body is
read, and the request is not counted for the rate limits; an address in
`SWWAF_ALLOW_NETS` too is let through. A client in
`SWWAF_RATE_LIMIT_EXEMPT_NETS` is neither counted nor refused by the rate
limits, and has no bytes counted by the byte limits; the country lists, the
rule files and bans still apply to it.
- In `observe` mode, with `SWWAF_MODE=observe`, refuses none of the requests
that `SWWAF_DENY_NETS`, a ban, the country lists, a blocklist, a DNSBL zone's
verdict, a rate limit or a rule would refuse: it passes them to the app, and
their log lines name what `enforce` mode would have done (see `would_action`
in "Request log" below). The checks run, and requests and bytes are counted,
as in `enforce` mode, with three differences: neither a broken rate limit or
byte limit nor a `ban` rule makes a ban; a broken limit does not set the
client's counters back to zero, so each request over a rate limit is logged as
one that would be refused, and each whose bytes keep the client over a byte
limit as breaking it; and a request under a ban does not make it permanent. As
in `enforce` mode, the bytes counted are only those of the requests `enforce`
mode would have passed to the app. A ban it would have made, or made
permanent, raises the alert `enforce` mode would have raised, marked as what
would have happened (see "Alerts" below). The bans in `bans.json` are kept,
and refuse requests again when `smallwebwaf` next runs in `enforce` mode, as
long as they last. The timeouts and size limits still apply, since they
protect `smallwebwaf` and the app themselves, and a request for one of
`smallwebwaf`'s own endpoints without its token is still answered `401`. It is
for trying a configuration before enforcing it.
verdict, AbuseIPDB's score, a rate limit or a rule would refuse: it passes
them to the app, and their log lines name what `enforce` mode would have done
(see `would_action` in "Request log" below). The checks run, and requests and
bytes are counted, as in `enforce` mode, with three differences: neither a
broken rate limit or byte limit nor a `ban` rule makes a ban; a broken limit
does not set the client's counters back to zero, so each request over a rate
limit is logged as one that would be refused, and each whose bytes keep the
client over a byte limit as breaking it; and a request under a ban does not
make it permanent. As in `enforce` mode, the bytes counted are only those of
the requests `enforce` mode would have passed to the app. A ban it would have
made, or made permanent, raises the alert `enforce` mode would have raised,
marked as what would have happened (see "Alerts" below). The bans in
`bans.json` are kept, and refuse requests again when `smallwebwaf` next runs
in `enforce` mode, as long as they last. The timeouts and size limits still
apply, since they protect `smallwebwaf` and the app themselves, and a request
for one of `smallwebwaf`'s own endpoints without its token is still answered
`401`. It is for trying a configuration before enforcing it.
- Answers `GET /_smallwebwaf/healthz` itself with `200` and `ok`, before any
check and without asking the app, for the image's health check.
- Answers `GET /_smallwebwaf/metrics` with its metrics (see "Metrics" below) for
@@ -270,14 +284,15 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
`SWWAF_LOG_REMOTE_URL` names one (see "Sending the log to a syslog server"
below).
- Sends an alert for each ban it makes or makes permanent, for a count over an
anomaly threshold, for a client a blocklist or a DNSBL zone lists, for GeoJS
failing, a list it cannot fetch or a DNSBL zone that fails or refuses a query,
for a rule file or state file with an error, and for a replacement of the
lookup database it cannot read, holding back repeats and, past an hourly
limit, rolling the rest into one summary, to each destination you name: as a
JSON object to the webhook `SWWAF_ALERT_WEBHOOK_URL` names, as a message to
the Slack incoming webhook `SWWAF_ALERT_SLACK_WEBHOOK_URL` names, and as a
message to the ntfy topic `SWWAF_ALERT_NTFY_URL` names (see "Alerts" below).
anomaly threshold, for a client a blocklist, a DNSBL zone or AbuseIPDB lists,
for GeoJS failing, a list it cannot fetch, a DNSBL zone or AbuseIPDB that
fails or refuses a query, and the day's AbuseIPDB checks used up, for a rule
file or state file with an error, and for a replacement of the lookup database
it cannot read, holding back repeats and, past an hourly limit, rolling the
rest into one summary, to each destination you name: as a JSON object to the
webhook `SWWAF_ALERT_WEBHOOK_URL` names, as a message to the Slack incoming
webhook `SWWAF_ALERT_SLACK_WEBHOOK_URL` names, and as a message to the ntfy
topic `SWWAF_ALERT_NTFY_URL` names (see "Alerts" below).
- Counts requests and their bytes over a minute and an hour, per client, per
netblock around a client, per AS number, for the whole service and per named
netblock, and sends an `anomaly` alert for a count over the anomaly threshold
@@ -340,8 +355,9 @@ effective settings are logged at start.
- `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body.
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body.
- `SWWAF_ALLOW_NETS` (default empty): netblocks whose clients skip bans, the
country lists, the blocklists, the DNSBL zones, the rate limits, the byte
limits and the rule files, such as your monitoring or your own networks.
country lists, the blocklists, the DNSBL zones, AbuseIPDB, the rate limits,
the byte limits and the rule files, such as your monitoring or your own
networks.
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients the
rate limits and the byte limits do not apply to, such as a machine that talks
to the app all day.
@@ -457,21 +473,28 @@ effective settings are logged at start.
`192.0.2.53` or `[2001:db8::53]:5353`. Unset, it is the host's, as
`/etc/resolv.conf` names it. Several zones refuse queries that come through a
public resolver.
- `SWWAF_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account,
which clients are checked with (see "AbuseIPDB" below). While it is unset, no
client is checked. The settings logged at start show `********` in its place.
- `SWWAF_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score,
from 0 to 100, that is a hit.
- `SWWAF_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day,
in UTC, a whole number above zero. AbuseIPDB's free accounts may make 1,000.
- `SWWAF_REPUTATION_ACTION` (default `limit:25`): what is done with a client a
zone's verdict lists, as `SWWAF_BLOCKLIST_ACTION` is for a blocklist: `deny`,
`limit:<percent>` or `log`. A zone's verdict is less certain than a list such
as DROP, so by default a client it lists gets a quarter of every rate limit
and byte limit.
zone's verdict lists, or whose AbuseIPDB score is a hit, as
`SWWAF_BLOCKLIST_ACTION` is for a blocklist: `deny`, `limit:<percent>` or
`log`. Such a verdict is less certain than a list such as DROP, so by default
its client gets a quarter of every rate limit and byte limit.
- `SWWAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict on a
client is used after the zone gave it.
- `SWWAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone may take
before it fails.
client, or AbuseIPDB's score of it, is used after it was given.
- `SWWAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone, or a
check with AbuseIPDB, may take before it fails.
- `SWWAF_BAN_RESPONSE` (default `403`): how a refused client is answered, one
that is banned, breaks a rate limit, matches a `ban` rule, is in
`SWWAF_DENY_NETS`, comes from a refused country, is in a blocklist while
`SWWAF_BLOCKLIST_ACTION` is `deny` or is listed by a DNSBL zone while
`SWWAF_REPUTATION_ACTION` is `deny`: `403`, `429`, or `close` to close the
connection without an answer. Behind traefik, `close` does not leave the
`SWWAF_BLOCKLIST_ACTION` is `deny` or is listed by a DNSBL zone or AbuseIPDB
while `SWWAF_REPUTATION_ACTION` is `deny`: `403`, `429`, or `close` to close
the connection without an answer. Behind traefik, `close` does not leave the
client unanswered: traefik answers `502`, as it does whenever its backend
drops a connection. A `block` rule always answers `403`.
- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken rate
@@ -616,7 +639,8 @@ listed twice in one of them stops the start. `off` switches a timeout, a size
limit, a rate limit, a byte limit, an anomaly threshold, `SWWAF_ALERT_COOLDOWN`
or `SWWAF_ALERT_MAX_PER_HOUR` off; `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`,
`SWWAF_LOOKUP_TIMEOUT`, `SWWAF_UNKNOWN_LIMIT_PERCENT`,
`SWWAF_BLOCKLIST_REFRESH`, `SWWAF_REPUTATION_CACHE_TTL`,
`SWWAF_BLOCKLIST_REFRESH`, `SWWAF_ABUSEIPDB_MIN_SCORE`,
`SWWAF_ABUSEIPDB_DAILY_BUDGET`, `SWWAF_REPUTATION_CACHE_TTL`,
`SWWAF_REPUTATION_TIMEOUT`, the ban settings, the state settings,
`SWWAF_METRICS_TOP_N`, `SWWAF_LOG_REMOTE_BUFFER`, `SWWAF_ANOMALY_NET_V4_PREFIX`
and `SWWAF_ANOMALY_NET_V6_PREFIX` cannot be off.
@@ -624,8 +648,9 @@ and `SWWAF_ANOMALY_NET_V6_PREFIX` cannot be off.
Several limits are fixed rather than settings. At most 20,000 clients are kept,
with their counters and history, and an IPv6 client is counted by its /64. At
most 100,000 answers from GeoJS are kept, for 7 days each, at most 20,000
anomaly counters, and at most 100,000 verdicts of the DNSBL zones, with at most
1,000 queries to them under way at once.
anomaly counters, at most 100,000 verdicts of the DNSBL zones, with at most
1,000 queries to them under way at once, and at most 100,000 scores of
AbuseIPDB.
### Settings given as files
@@ -709,30 +734,31 @@ which every line has.
- `request_bytes` and `response_bytes` count body bytes.
- `action` is `forward` for a request passed to the app, `denied` for one
refused because its client is in `SWWAF_DENY_NETS`, in a blocklist while
`SWWAF_BLOCKLIST_ACTION` is `deny`, or listed by a DNSBL zone while
`SWWAF_REPUTATION_ACTION` is `deny`, `banned` for one refused because a ban
covers its client or because it matched a `ban` rule, which bans its client,
`country_denied` for one refused for its client's country, `rate_limited` for
one that broke a rate limit and banned its client, `rule_blocked` for one a
`block` rule refused, `too_large` for a request or response over its size
limit, `timed_out` for one that ran out of time, `upstream_error` when the app
could not be reached or its answer broke off, and `admin` for one
`smallwebwaf` answered at its own endpoint.
`SWWAF_BLOCKLIST_ACTION` is `deny`, or listed by a DNSBL zone or AbuseIPDB
while `SWWAF_REPUTATION_ACTION` is `deny`, `banned` for one refused because a
ban covers its client or because it matched a `ban` rule, which bans its
client, `country_denied` for one refused for its client's country,
`rate_limited` for one that broke a rate limit and banned its client,
`rule_blocked` for one a `block` rule refused, `too_large` for a request or
response over its size limit, `timed_out` for one that ran out of time,
`upstream_error` when the app could not be reached or its answer broke off,
and `admin` for one `smallwebwaf` answered at its own endpoint.
- `would_action` is there in `observe` mode for a request that
`SWWAF_DENY_NETS`, a ban, the country lists, a blocklist, a DNSBL zone's
verdict, a rate limit or a rule would have refused in `enforce` mode, and
names the action that refusal would have had: `denied`, `banned`,
`country_denied`, `rate_limited` or `rule_blocked`. `action` then names what
was done: `forward` for a request passed to the app, and another action, such
as `too_large`, for one a size or time limit refused.
verdict, AbuseIPDB's score, a rate limit or a rule would have refused in
`enforce` mode, and names the action that refusal would have had: `denied`,
`banned`, `country_denied`, `rate_limited` or `rule_blocked`. `action` then
names what was done: `forward` for a request passed to the app, and another
action, such as `too_large`, for one a size or time limit refused.
- `limit_percent` is there for a request the rate limits count whose client a
biased threshold, `SWWAF_BLOCKLIST_ACTION` for a blocklist that lists it, or
`SWWAF_REPUTATION_ACTION` for a DNSBL zone whose verdict lists it, gives less
than the whole of the rate limits, and gives the percentage it gets, with
`limit_percent_setting` naming the setting that gave it, such as
`SWWAF_ASN_LIMIT_PERCENT`, or `SWWAF_ASN_LIMIT_PERCENT_URL` for the file it
names. `bytes_percent` and `bytes_percent_setting` are the same for the byte
limits. Each is left out when the client gets the whole of those limits.
`SWWAF_REPUTATION_ACTION` for a DNSBL zone whose verdict lists it or an
AbuseIPDB score that is a hit, gives less than the whole of the rate limits,
and gives the percentage it gets, with `limit_percent_setting` naming the
setting that gave it, such as `SWWAF_ASN_LIMIT_PERCENT`, or
`SWWAF_ASN_LIMIT_PERCENT_URL` for the file it names. `bytes_percent` and
`bytes_percent_setting` are the same for the byte limits. Each is left out
when the client gets the whole of those limits.
- `counts` gives the client's requests in the minute, the hour and the day as
the rate limits count them, this request included: in each window, those in
the bucket under way and a share of those in the bucket before, so a count can
@@ -741,11 +767,11 @@ which every line has.
health check, one from a client in `SWWAF_ALLOW_NETS` or
`SWWAF_RATE_LIMIT_EXEMPT_NETS`, one for a path that
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` exempts, and one that `SWWAF_DENY_NETS`, a
ban, the country lists, a blocklist or a DNSBL zone's verdict refuse, or would
refuse in `observe` mode. Its `minute_bytes`, `hour_bytes` and `day_bytes`
give the client's bytes in each window as the byte limits count them, in the
same way: for a request whose bytes they count, with its own, once it has
ended; for any other, those counted before it.
ban, the country lists, a blocklist, a DNSBL zone's verdict or AbuseIPDB's
score refuse, or would refuse in `observe` mode. Its `minute_bytes`,
`hour_bytes` and `day_bytes` give the client's bytes in each window as the
byte limits count them, in the same way: for a request whose bytes they count,
with its own, once it has ended; for any other, those counted before it.
- `rule_ids` is there for a request that matched rules of the rule files, and
lists their ids in the order they matched, up to the one that refused it.
- `limit_hit` is there for a request that broke a rate limit, or whose bytes
@@ -756,15 +782,17 @@ which every line has.
is not refused: its `action` is what it would have been otherwise, such as
`forward`.
- `reputation` is there for a request whose client a blocklist or a DNSBL zone's
verdict lists, and gives the URLs of the blocklists that list it, in the order
`SWWAF_BLOCKLIST_URLS` names them, then the zones whose verdict lists it, in
the order `SWWAF_DNSBL_ZONES` names them, whatever `SWWAF_BLOCKLIST_ACTION`
and `SWWAF_REPUTATION_ACTION` say. A zone whose verdict on the client has not
come yet, or was given `SWWAF_REPUTATION_CACHE_TTL` ago or more, is not named.
It is left out for a client the blocklists are not checked for: one in
`SWWAF_ALLOW_NETS`, and one `SWWAF_DENY_NETS`, a ban or the country lists
refuse first, or would in `observe` mode. The zones are not checked either for
a client a blocklist refuses, or would in `observe` mode.
verdict lists, or whose AbuseIPDB score is a hit, and gives the URLs of the
blocklists that list it, in the order `SWWAF_BLOCKLIST_URLS` names them, then
the zones whose verdict lists it, in the order `SWWAF_DNSBL_ZONES` names them,
then `abuseipdb`, whatever `SWWAF_BLOCKLIST_ACTION` and
`SWWAF_REPUTATION_ACTION` say. A zone whose verdict on the client has not come
yet, or was given `SWWAF_REPUTATION_CACHE_TTL` ago or more, is not named, nor
is AbuseIPDB for such a score. It is left out for a client the blocklists are
not checked for: one in `SWWAF_ALLOW_NETS`, and one `SWWAF_DENY_NETS`, a ban
or the country lists refuse first, or would in `observe` mode. The zones are
not checked either for a client a blocklist refuses, nor AbuseIPDB's score for
one a blocklist or a zone refuses, or would in `observe` mode.
- `ban_expires` is there for a request that made a ban or was refused under one,
or in `observe` mode would have been refused under one, and gives when the ban
ends, in the same form as `time`, or `permanent`.
@@ -835,12 +863,14 @@ it, as below. An alert is for one of these events, and is sent when
each request that ends with the count over it, in `observe` mode as in
`enforce` mode. It refuses and bans nothing.
- `reputation_hit`: a request whose client a blocklist or a DNSBL zone's verdict
lists, one alert for each blocklist and each zone that lists it, whatever
lists, or whose AbuseIPDB score is a hit, one alert for each blocklist and
each zone that lists it, and one for AbuseIPDB, whatever
`SWWAF_BLOCKLIST_ACTION` and `SWWAF_REPUTATION_ACTION` say, in `observe` mode
as in `enforce` mode.
- `source_failure`: GeoJS failing or refusing `smallwebwaf`, a fetch of a list
failing (see "Blocklists" below), or a query to a DNSBL zone failing or
refused (see "DNS blocklists" below).
failing (see "Blocklists" below), a query to a DNSBL zone failing or refused
(see "DNS blocklists" below), or a check with AbuseIPDB failing or refused, or
the check that uses up the day's AbuseIPDB checks (see "AbuseIPDB" below).
- `file_error`: a rule file edited while it runs that has an error, an edit of a
state file set aside as `<name>.bad`, a state file it could not write while
running, or a replacement of the lookup database it could not read, which it
@@ -913,7 +943,8 @@ is sent on one line:
- `reason` is a short sentence; for a ban, the ban's `reason` in `bans.json`;
for an `anomaly`, what was counted over which threshold, such as
`requests per minute of the netblock 203.0.113.0/24 over the threshold of 1000`;
for a `reputation_hit`, `listed by a blocklist` or `listed by a DNSBL zone`.
for a `reputation_hit`, `listed by a blocklist`, `listed by a DNSBL zone` or
`scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE`.
- `detail` is what is particular to the event: for a ban, its `cause`, when it
ends as `ban_expires`, in the form the request log gives it, and its `notes`,
as `bans.json` gives them; for an `anomaly`, the `scope`, `client`, `net`,
@@ -921,11 +952,12 @@ is sent on one line:
`asn` and the `name` of the named netblock for `watch`, the `window`, `minute`
or `hour`, the `kind`, `requests` or `bytes`, the `count`, which is weighted
as the rate limits weigh theirs, and the `threshold`; for a `reputation_hit`,
the `source`, the URL of the blocklist or the zone; for `source_failure`, the
`source`, `geojs`, the URL of the list or the zone, the `error`, and for
GeoJS, when it is asked again, `asking_again_in`; for `file_error`, the
`file`, which for an edit set aside is the file it was renamed to, and the
`error`, which for a file that does not parse names where in it the error is.
the `source`, the URL of the blocklist, the zone or `abuseipdb`, and for
AbuseIPDB the `score`; for `source_failure`, the `source`, `geojs`, the URL of
the list, the zone or `abuseipdb`, the `error`, and for GeoJS, when it is
asked again, `asking_again_in`; for `file_error`, the `file`, which for an
edit set aside is the file it was renamed to, and the `error`, which for a
file that does not parse names where in it the error is.
- `suppressed_repeats` is how many repeats the cooldown held back before this
alert, and for a `summary`, those no other alert gives (see below).
@@ -971,14 +1003,14 @@ and Slack this JSON object, shown indented; it is sent on one line:
```
An alert for the same event as the last one sent, on the same netblock, and for
a `reputation_hit` about the same blocklist or zone, or for a `file_error` about
the same file, or for a `source_failure` about the same source, or for an
`anomaly` in the same scope, with the same netblock, AS number or name, whatever
its window and kind, less than `SWWAF_ALERT_COOLDOWN` after it, is a repeat: it
is held back and counted, and the next alert sent for them gives that count as
`suppressed_repeats`. As each hour of the clock, in UTC, ends, the cooldowns
that have run out are dropped, and the repeats they held back, which no alert
sent since has given, go in that hour's summary.
a `reputation_hit` about the same blocklist, zone or AbuseIPDB, or for a
`file_error` about the same file, or for a `source_failure` about the same
source, or for an `anomaly` in the same scope, with the same netblock, AS number
or name, whatever its window and kind, less than `SWWAF_ALERT_COOLDOWN` after
it, is a repeat: it is held back and counted, and the next alert sent for them
gives that count as `suppressed_repeats`. As each hour of the clock, in UTC,
ends, the cooldowns that have run out are dropped, and the repeats they held
back, which no alert sent since has given, go in that hour's summary.
Past `SWWAF_ALERT_MAX_PER_HOUR` alerts in an hour, the hour's other alerts are
held back and counted by event. An alert held back this way starts no cooldown.
@@ -1043,8 +1075,12 @@ with times in UTC.
left out while no fetch of it has succeeded; and under `verdicts`, each
verdict of a DNSBL zone still in use (see "DNS blocklists" below): its `zone`,
the `client`'s address, whether the zone `listed` the client, and when the
zone gave it, `fetched`. As the file is read, the lists the settings no longer
name, and the verdicts of the zones they no longer name, are dropped.
zone gave it, `fetched`; and under `abuseipdb` (see "AbuseIPDB" below), the
`day`, in UTC, whose checks it counts, left out before the first, the checks
`spent` that day, and under `scores`, each score of AbuseIPDB still in use:
the `client`, its IPv4 address as a /32 or its IPv6 /64, its `score`, and when
AbuseIPDB gave it, `fetched`. As the file is read, the lists the settings no
longer name, and the verdicts of the zones they no longer name, are dropped.
- `alerts.json`: the state of the alerts (see "Alerts" above), indented to be
read: under `cooldowns`, for each event and netblock, with the `source` too
for a `reputation_hit`, or event and `file` or `source`, or for an `anomaly`,
@@ -1298,6 +1334,13 @@ scraped, and keeps this one as `exported_instance` unless the scrape sets
verdict lists, a series that comes with the first;
`smallwebwaf_reputation_queries_total`: the queries made to the zone; and
`smallwebwaf_reputation_failures_total`: those that failed.
- While `SWWAF_ABUSEIPDB_KEY` is set, by `source`, `abuseipdb`:
`smallwebwaf_reputation_hits_total`: the requests whose client's AbuseIPDB
score is a hit, a series that comes with the first;
`smallwebwaf_reputation_queries_total`: the checks made, each of which spends
one of the day's budget; `smallwebwaf_reputation_failures_total`: those that
failed; and `smallwebwaf_reputation_daily_budget_remaining`: the checks the
day's `SWWAF_ABUSEIPDB_DAILY_BUDGET` has left.
- `smallwebwaf_tracked_clients`: the clients in the table of clients.
- `smallwebwaf_state_file_writes_total`,
`smallwebwaf_state_file_write_failures_total`,
@@ -1490,8 +1533,9 @@ goes through the candidates one by one.
nothing. Edit a file, or add a rule file, and the running `smallwebwaf` picks
up the change. Nothing is read from disk while serving a request. The files
for the bans, the clients, the GeoJS answers, the copies of the lists, the
verdicts of the DNSBL zones and the alerts are built, with an edit taken in
while running (see "State files" above); the rest comes with its features.
verdicts of the DNSBL zones, AbuseIPDB's scores and the alerts are built, with
an edit taken in while running (see "State files" above); the rest comes with
its features.
- Health checks, the metrics, and listing, adding and lifting bans or asking why
a given address was refused, all on the one port every request uses: under
`/_smallwebwaf/` on the app's own address, through traefik like any other
@@ -1771,6 +1815,57 @@ with its key. A key in the name of any other zone is shown as given. Several
zones refuse queries that come through a public resolver; `SWWAF_DNSBL_RESOLVER`
names another resolver to ask through.
## AbuseIPDB
While `SWWAF_ABUSEIPDB_KEY` holds the key of an AbuseIPDB account, `smallwebwaf`
asks AbuseIPDB's check endpoint, `https://api.abuseipdb.com/api/v2/check`, for
the abuse confidence score of a client's own address, from 0 to 100. It is unset
by default, for the reason no blocklist is named, and since AbuseIPDB needs an
account. An IPv6 client, a /64, is checked by the address of the request that
has it checked, and its score is used for the whole /64, whichever of its
addresses sends, so that one client costs at most one check every
`SWWAF_REPUTATION_CACHE_TTL`.
Only a client whose history counts an offence is checked, so that the checks are
spent on suspects: so far, one that has broken a rate limit or a byte limit,
matched a ban rule, or had a request refused by a block rule. A client dropped
from the table of clients loses its history, and with it its offences. A client
is checked in the background, at its first request after its offence that
reaches the check: no request waits, a request refused under its ban is not
checked, and the request that has it checked, and any other from it before the
answer comes, goes on as from a client without a score.
A score at or over `SWWAF_ABUSEIPDB_MIN_SCORE`, 75 by default, is a hit, and
`SWWAF_REPUTATION_ACTION` says what is done with its client, as for a DNSBL
zone's verdict (see "What it does so far" above). Each score, a hit or not, is
kept, in memory and in `reputation.json` (see "State files" above), and used for
`SWWAF_REPUTATION_CACHE_TTL` after AbuseIPDB gave it, 24 hours by default. The
client's first request after that has it checked again, if its history still
counts an offence. At most 100,000 scores are kept, the one fetched longest ago
dropped first.
At most `SWWAF_ABUSEIPDB_DAILY_BUDGET` checks are made in a day, 900 by default,
below the 1,000 of AbuseIPDB's free accounts. The day is counted in UTC, from
00:00. Each check sent spends one of them, whatever AbuseIPDB answers, and the
checks spent today are kept in `reputation.json`, so that a restart does not
make the budget whole again. The check that spends the last of the day's budget
is logged and raised as a `source_failure` alert; from then until the day ends,
no client is checked, and a client without a score counts as one AbuseIPDB does
not list.
A check fails when AbuseIPDB answers other than `200`, such as `429` past its
own daily limit or `401` for a wrong key, when its answer gives no
`abuseConfidenceScore`, and when it does not answer within
`SWWAF_REPUTATION_TIMEOUT`. A failure gives no score, and is counted, logged and
raised as a `source_failure` alert, held back as a repeat within
`SWWAF_ALERT_COOLDOWN`, and no client is checked for a minute after it.
The key is sent to AbuseIPDB in the `Key` header, and nowhere else. The settings
logged at start show `********` in its place, and neither the log, the alerts,
the metrics nor `reputation.json` hold it. Given as a file, with
`SWWAF_ABUSEIPDB_KEY_FILE`, it can be kept out of the app's reach (see "Settings
given as files" above).
## How the code is laid out
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
@@ -1785,14 +1880,14 @@ names another resolver to ask through.
limits, and writes the request's log line. Its `check` method is where a
request is refused before anything reaches the app: for `SWWAF_DENY_NETS`, for
a ban, for the country lists, for a blocklist, for a DNSBL zone's verdict, for
a rate limit, which bans the client, for a `block` or `ban` rule, the latter
banning the client, and for an announced body over the size limit; in
`observe` mode, only for the size limit, with what it would have refused for
noted in the log line. A request under `/_smallwebwaf/` that `check` lets
through is answered by `answerAdmin` instead of reaching the app. Once the
answer to a request passed to the app has ended, `countBytes` counts its bytes
for the byte limits, and once any request but the health check has ended,
`countAnomalies` counts it for the anomaly thresholds.
AbuseIPDB's score, for a rate limit, which bans the client, for a `block` or
`ban` rule, the latter banning the client, and for an announced body over the
size limit; in `observe` mode, only for the size limit, with what it would
have refused for noted in the log line. A request under `/_smallwebwaf/` that
`check` lets through is answered by `answerAdmin` instead of reaching the app.
Once the answer to a request passed to the app has ended, `countBytes` counts
its bytes for the byte limits, and once any request but the health check has
ended, `countAnomalies` counts it for the anomaly thresholds.
- `internal/metrics`: the metrics, counted as the other parts tell it what
happened, and served in the Prometheus text format.
- `internal/bans`: the ban ledger: each netblock's bans with their notes, how
@@ -1808,9 +1903,11 @@ names another resolver to ask through.
- `internal/reputation`: fetches the blocklists and the file
`SWWAF_ASN_LIMIT_PERCENT_URL` names as they are due, keeps the last good copy
of each, and tells which blocklists list an address and what percentage the
file gives an AS number; and asks the DNSBL zones about clients in the
background, through the standard library's resolver, keeps their verdicts, and
tells which zones' verdicts list an address.
file gives an AS number; asks the DNSBL zones about clients in the background,
through the standard library's resolver, keeps their verdicts, and tells which
zones' verdicts list an address; and checks clients with AbuseIPDB in the
background, keeps their scores and the checks spent today, and tells whether a
client's score is a hit.
- `internal/ratelimit`: the table of clients: counts each client's requests and
bytes, tells when they take it over a rate limit or a byte limit, and keeps
each client's history.
@@ -1838,11 +1935,11 @@ names another resolver to ask through.
Besides the Go standard library, `github.com/hashicorp/golang-lru/v2` keeps the
table of clients to 20,000 and the GeoJS answers to 100,000, dropping the least
recently seen, the DNSBL zones' verdicts to 100,000, dropping the one fetched
longest ago, the anomaly counters to 20,000, dropping the one counted least
recently, and the banned netblocks in the order they were last seen, from which
the ledger picks the ban to drop past `SWWAF_MAX_BANS`, and
`github.com/prometheus/client_golang` keeps the metrics and serves them, and
recently seen, the DNSBL zones' verdicts and AbuseIPDB's scores to 100,000 each,
dropping the one fetched longest ago, the anomaly counters to 20,000, dropping
the one counted least recently, and the banned netblocks in the order they were
last seen, from which the ledger picks the ban to drop past `SWWAF_MAX_BANS`,
and `github.com/prometheus/client_golang` keeps the metrics and serves them, and
`github.com/fsnotify/fsnotify` tells `smallwebwaf` when a state file or a rule
file is saved, or the lookup database replaced, and
`github.com/oschwald/maxminddb-golang/v2` reads the lookup database, which the