1 Commits
Author SHA1 Message Date
sneak 45eb82db37 The README's AbuseIPDB section names every offence that has a client checked (closes #151)
check / check (push) Canceled after 0s
The section listed only a broken rate limit or byte limit, a ban rule
match, and a refusal by a block rule or the Core Rule Set. A client is
checked once its history counts any offence, so it now also names the
error burst, a request for a trap path and a refusal for a missing or
wrong token, as "What it does so far" does.

Model: opus-5-5
2026-10-20 15:22:04 +00:00
4 changed files with 18 additions and 40 deletions
+13 -15
View File
@@ -1449,12 +1449,11 @@ whole service, its `asn`, for an AS number, its `name`, for a named netblock, or
the `start` of a window in which it has requests or bytes; a list's `url`, the `start` of a window in which it has requests or bytes; a list's `url`,
`fetched` or `lines`, which is `[]` for an empty list; a verdict's `zone`, `fetched` or `lines`, which is `[]` for an empty list; a verdict's `zone`,
`client`, `listed`, which is `false` for a client the zone does not list, or `client`, `listed`, which is `false` for a client the zone does not list, or
`fetched`. So does a ban whose `expires` is not after its `start` or whose `fetched`. So does a ban whose `cause` is not `limit`, `attack`, `admin` or
`cause` is not `limit`, `attack`, `admin` or `crowdsec`, alerts waiting for a `crowdsec`, alerts waiting for a destination that is not `webhook`, `slack` or
destination that is not `webhook`, `slack` or `ntfy`, an anomaly counter whose `ntfy`, an anomaly counter whose `scope` is not `client`, `net`, `asn`, `total`
`scope` is not `client`, `net`, `asn`, `total` or `watch`, and a copy of a list or `watch`, and a copy of a list with a line that would make its fetch fail. An
with a line that would make its fetch fail. An answer's `asn` or `as_name` left answer's `asn` or `as_name` left out reads as empty.
out reads as empty.
While it runs, `smallwebwaf` watches `SWWAF_STATE_DIR` and takes in your edit of While it runs, `smallwebwaf` watches `SWWAF_STATE_DIR` and takes in your edit of
a state file as soon as you save it: what the file then holds replaces what a state file as soon as you save it: what the file then holds replaces what
@@ -1464,15 +1463,14 @@ writes a file it takes in any edit made since, so your edit is not overwritten;
a change `smallwebwaf` made after you opened the file, such as a new ban, is a change `smallwebwaf` made after you opened the file, such as a new ban, is
lost when you save over it. An edit that would stop the start, because it does lost when you save over it. An edit that would stop the start, because it does
not parse, has another `version`, leaves out a field an entry needs, gives a ban not parse, has another `version`, leaves out a field an entry needs, gives a ban
an `expires` not after its `start` or another `cause`, names another another `cause`, names another destination, gives an anomaly counter another
destination, gives an anomaly counter another `scope` or gives a list's copy a `scope` or gives a list's copy a line that would make its fetch fail, does not
line that would make its fetch fail, does not stop the running `smallwebwaf`: it stop the running `smallwebwaf`: it keeps what it holds, and at the file's next
keeps what it holds, and at the file's next write renames your file to write renames your file to `<name>.bad`, such as `bans.json.bad`, writes the
`<name>.bad`, such as `bans.json.bad`, writes the file again from memory, logs file again from memory, logs the file and where the error is, and raises a
the file and where the error is, and raises a `file_error` alert for it. It `file_error` alert for it. It waits for that write because an editor's file can
waits for that write because an editor's file can be read before the editor has be read before the editor has finished writing it. Mend the `.bad` file and move
finished writing it. Mend the `.bad` file and move it back. A file you remove is it back. A file you remove is written again at its next write.
written again at its next write.
To ban a netblock, add an entry to `bans.json` with its `netblock`, its `start` To ban a netblock, add an entry to `bans.json` with its `netblock`, its `start`
and its `expires`, `null` for a ban that never ends; its `reason` and its and its `expires`, `null` for a ban that never ends; its `reason` and its
+3 -3
View File
@@ -225,9 +225,9 @@ address or one /64, so a permanent ban does not reach neighbours who did
nothing. nothing.
An offence is a request `smallwebwaf` holds against the client: one that carries An offence is a request `smallwebwaf` holds against the client: one that carries
a clear sign of attack, breaks a limit, or is refused by a rule file, by the a clear sign of attack, breaks a limit, or is refused by a rule file or the Core
Core Rule Set or for a missing or wrong token. Offences are counted by kind in Rule Set. Offences are counted by kind in the client's history. Two kinds lead
the client's history. Two kinds lead to a ban, each by its own rule: to a ban, each by its own rule:
- A clear sign of attack: a request that matches a rule file rule whose action - A clear sign of attack: a request that matches a rule file rule whose action
is `ban`, or asks for a path in `SWWAF_TRAP_PATHS`. Examples are a probe for a is `ban`, or asks for a path in `SWWAF_TRAP_PATHS`. Examples are a probe for a
+2 -7
View File
@@ -61,7 +61,6 @@ var (
// errMissing is for an entry without a field it needs. // errMissing is for an entry without a field it needs.
errMissing = errors.New("has no") errMissing = errors.New("has no")
errCause = errors.New("is not limit, attack, admin or crowdsec") errCause = errors.New("is not limit, attack, admin or crowdsec")
errExpires = errors.New("is not after its start")
errDestination = errors.New("is not webhook, slack or ntfy") errDestination = errors.New("is not webhook, slack or ntfy")
errScope = errors.New("is not client, net, asn, total or watch") errScope = errors.New("is not client, net, asn, total or watch")
errWaitingList = errors.New(`waiting is a list, but now lists the alerts by ` + errWaitingList = errors.New(`waiting is a list, but now lists the alerts by ` +
@@ -647,10 +646,8 @@ func (e BanEntry) ban() bans.Ban {
// client, a start, from which the length of the netblock's next ban is // client, a start, from which the length of the netblock's next ban is
// worked out, or an expires, which would make it permanent. A permanent // worked out, or an expires, which would make it permanent. A permanent
// ban's expires is null, which Bans cannot tell from a missing one, so // ban's expires is null, which Bans cannot tell from a missing one, so
// each expires is read again as written. An expires not after the start // each expires is read again as written. A cause other than limit,
// is refused too, since the netblock's next ban for a broken limit, three // attack, admin or crowdsec, most likely misspelt, is refused too.
// times as long as this one, would then refuse nothing; and so is a cause
// other than limit, attack, admin or crowdsec, most likely misspelt.
func (f *bansFile) check(data []byte) error { func (f *bansFile) check(data []byte) error {
var written struct { var written struct {
Bans []struct { Bans []struct {
@@ -671,8 +668,6 @@ func (f *bansFile) check(data []byte) error {
return missing(i, "start") return missing(i, "start")
case written.Bans[i].Expires == nil: case written.Bans[i].Expires == nil:
return missing(i, "expires") return missing(i, "expires")
case entry.Expires != nil && !entry.Expires.After(entry.Start):
return fmt.Errorf("entry %d's expires %w", i+1, errExpires)
case entry.Cause != "" && entry.Cause != bans.CauseLimit && case entry.Cause != "" && entry.Cause != bans.CauseLimit &&
entry.Cause != bans.CauseAttack && entry.Cause != bans.CauseAdmin && entry.Cause != bans.CauseAttack && entry.Cause != bans.CauseAdmin &&
entry.Cause != bans.CauseCrowdSec: entry.Cause != bans.CauseCrowdSec:
-15
View File
@@ -865,21 +865,6 @@ func TestBanWithAnotherCauseStopsTheStart(t *testing.T) {
`: entry 4's cause "atack" is not limit, attack, admin or crowdsec`) `: entry 4's cause "atack" is not limit, attack, admin or crowdsec`)
} }
func TestBanThatDoesNotEndAfterItsStartStopsTheStart(t *testing.T) {
t.Parallel()
// The first ban ends a second after its start; the second ends as it
// starts, or an hour before.
for _, expires := range []string{`"2026-10-06T00:00:00Z"`, `"2026-10-05T23:00:00Z"`} {
wantRefused(t, bansJSON, `{"version": 1, "bans": [`+
`{"netblock": "203.0.113.9/32", "start": "2026-10-06T00:00:00Z", `+
`"expires": "2026-10-06T00:00:01Z"}, `+
`{"netblock": "203.0.113.10/32", "start": "2026-10-06T00:00:00Z", `+
`"expires": `+expires+`}]}`,
`: entry 2's expires is not after its start`)
}
}
func TestUnknownVersionStopsTheStart(t *testing.T) { func TestUnknownVersionStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()