SPEC: each requirement once, lists moved to docs/, repeats and sections dropped
check / check (push) Waiting to run
check / check (push) Waiting to run
Every requirement the second review found in two to five places is now stated once, with links from the other places. "Risks the design has to handle", "Rollout per service" and most of "Architecture" are gone, their two points of their own moved to "Attack detection" and "Anomaly thresholds". The request log's fields, the metrics and the image's contents move to docs/request-log.md, docs/metrics.md and docs/image.md, each linked from the spec. The off rule names SWWAF_LOOKUP_SOURCE and SWWAF_WAF_MODE, the reputation bound is given, and the six Core Rule Set changes are a numbered list. Model: fable-5-1
This commit is contained in:
@@ -172,6 +172,18 @@ the runner. More than 30 such refusals from its address within a minute, as when
|
|||||||
many jobs upload at once, ban it through the error burst.
|
many jobs upload at once, ban it through the error burst.
|
||||||
`actions/upload-artifact@v4` does not send the header.
|
`actions/upload-artifact@v4` does not send the header.
|
||||||
|
|
||||||
|
## What body inspection refuses
|
||||||
|
|
||||||
|
With `SWWAF_WAF_BODY_LIMIT` set, the Core Rule Set reads request bodies too. In
|
||||||
|
front of gitea it then refuses these, until their rule ids, named in the request
|
||||||
|
log, are added to `SWWAF_WAF_DISABLED_RULES`:
|
||||||
|
|
||||||
|
- issue and comment text, wiki pages and files saved in the web editor that hold
|
||||||
|
shell commands or code (932125, 932235, 932250 and others);
|
||||||
|
- package descriptions that show code;
|
||||||
|
- PyPI uploads (922130);
|
||||||
|
- attachments named like `debug.log` or `config.yml` (932180).
|
||||||
|
|
||||||
## What `script/gitea-requests` sends
|
## What `script/gitea-requests` sends
|
||||||
|
|
||||||
Of the requests above, the script sends:
|
Of the requests above, the script sends:
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# What the image holds
|
||||||
|
|
||||||
|
The `smallwebwaf` image is the base of each app's own image. The requirements
|
||||||
|
are under "Deployment" in [`SPEC.md`](../SPEC.md); this file lists what the
|
||||||
|
image holds, how each part is pinned, and why.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- Ubuntu 26.04 LTS, the newest long-term support release of Ubuntu, pinned by
|
||||||
|
digest. The image moves to the next LTS release when that ships.
|
||||||
|
- `ca-certificates`, `nix-bin` and `runit` from Ubuntu, installed from a dated
|
||||||
|
snapshot of its archive and checked by hash. The Ubuntu image has no CA
|
||||||
|
certificates; without them Nix cannot download packages and `smallwebwaf`
|
||||||
|
cannot reach GeoJS.
|
||||||
|
- Nix and nixpkgs, fixed at one release. Root uses Nix directly; no Nix daemon
|
||||||
|
runs. `nix-bin` creates no build users, so `build-users-group =` in
|
||||||
|
`/etc/nix/nix.conf` lets root build without them.
|
||||||
|
- runit, and `runsvinit` as the entrypoint, as the owner's code style guide asks
|
||||||
|
for service containers. No package carries `runsvinit`, so the image builds it
|
||||||
|
from its archived source (`github.com/peterbourgon/runsvinit`, unchanged
|
||||||
|
since 2015) at a fixed commit hash, with a `go.mod` the build writes.
|
||||||
|
`runsvinit` starts runit's `runsvdir`, which runs the `run` script of each
|
||||||
|
directory under `/etc/service` and runs it again whenever it exits. Ubuntu's
|
||||||
|
`sv` looks for services there too.
|
||||||
|
- The `smallwebwaf` binary, and a user of its own, `smallwebwaf` (uid and gid
|
||||||
|
65532).
|
||||||
|
- The service directory `/etc/service/smallwebwaf`. Its `run` script waits one
|
||||||
|
second (`sleep 1`), makes `SWWAF_STATE_DIR` and every file in it belong to the
|
||||||
|
`smallwebwaf` user, and starts `smallwebwaf` as that user with runit's
|
||||||
|
`chpst`.
|
||||||
|
- The directory `/var/lib/smallwebwaf` for the state files, and
|
||||||
|
`/etc/smallwebwaf/rules.d` with the default rule file.
|
||||||
|
- Port 8080 declared (`EXPOSE 8080`), and the health check (`HEALTHCHECK`) that
|
||||||
|
"The health check" in `SPEC.md` describes.
|
||||||
|
|
||||||
|
## Pinning
|
||||||
|
|
||||||
|
- Ubuntu's packages come from Ubuntu's snapshot service, the archive as it was
|
||||||
|
at the moment the Dockerfile names (`--snapshot 20261001T000000Z`), never
|
||||||
|
earlier than the pinned Ubuntu image's date. The Dockerfile names the SHA-256
|
||||||
|
hash of each of the snapshot's `InRelease` files, which cover every package
|
||||||
|
list and so every package, and the build checks them before installing. The
|
||||||
|
hashes are the amd64 archive's, so the image is built for amd64 only. The
|
||||||
|
snapshot is fetched over HTTPS with the CA certificates of the Go image that
|
||||||
|
builds `smallwebwaf`, itself pinned by digest, since the Ubuntu image has
|
||||||
|
none.
|
||||||
|
- nixpkgs comes from a release file on `releases.nixos.org` (`nixexprs.tar.xz`
|
||||||
|
of `nixos-26.05.11045.774debe7a0d1`), named with its SHA-256 hash, not from a
|
||||||
|
GitHub archive, whose bytes can change. It is set up for root as `nixpkgs`.
|
||||||
|
Root's Nix profile, `/nix/var/nix/profiles/default/bin`, is last on the
|
||||||
|
`PATH`, so that no package hides the image's own commands; busybox's `sv`, for
|
||||||
|
one, looks for services elsewhere. Unpacked, nixpkgs takes about 500 MiB.
|
||||||
|
- An app built on the image gets the same packages each time it is built.
|
||||||
|
Security fixes from Ubuntu and nixpkgs come with a newer `smallwebwaf` image,
|
||||||
|
which an app takes by changing the digest in its `FROM` line. The image moves
|
||||||
|
to each new nixpkgs release, every six months.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# The metrics
|
||||||
|
|
||||||
|
`GET /_smallwebwaf/metrics` answers in the Prometheus text format. The
|
||||||
|
requirements are under "Metrics endpoint" in [`SPEC.md`](../SPEC.md); this file
|
||||||
|
lists the metrics. Every metric carries the label `instance`, set to
|
||||||
|
`SWWAF_INSTANCE_NAME`; Prometheus keeps it as `exported_instance` unless the
|
||||||
|
scrape sets `honor_labels: true`. No metric carries a client's address.
|
||||||
|
|
||||||
|
## Traffic
|
||||||
|
|
||||||
|
- `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and
|
||||||
|
`smallwebwaf_response_bytes_total`, by `status_class` such as `2xx` and by
|
||||||
|
`action`;
|
||||||
|
- `smallwebwaf_request_duration_seconds` and
|
||||||
|
`smallwebwaf_upstream_duration_seconds`, as histograms;
|
||||||
|
- `smallwebwaf_requests_in_flight`.
|
||||||
|
|
||||||
|
## Limits and bans
|
||||||
|
|
||||||
|
- `smallwebwaf_rate_limit_hits_total`, by `window` and by `kind` (`requests`,
|
||||||
|
`bytes` or `refusals`);
|
||||||
|
- `smallwebwaf_size_and_time_limit_hits_total`, by `limit`, the setting passed;
|
||||||
|
- `smallwebwaf_offences_total`, by `kind`;
|
||||||
|
- `smallwebwaf_bans_made_total`, by `cause`;
|
||||||
|
- `smallwebwaf_active_bans` and `smallwebwaf_permanent_bans`.
|
||||||
|
|
||||||
|
## Attack detection
|
||||||
|
|
||||||
|
- `smallwebwaf_rule_matches_total`, by `rule_id` and `action`, and
|
||||||
|
`smallwebwaf_rules_loaded`;
|
||||||
|
- `smallwebwaf_waf_matches_total`, by `mode` and `rule_id`, for the rules that
|
||||||
|
fired.
|
||||||
|
|
||||||
|
## Countries and AS numbers
|
||||||
|
|
||||||
|
For the `SWWAF_METRICS_TOP_N` busiest of each, the rest as `other`:
|
||||||
|
|
||||||
|
- `smallwebwaf_country_requests_total`,
|
||||||
|
`smallwebwaf_country_request_bytes_total`,
|
||||||
|
`smallwebwaf_country_response_bytes_total` and
|
||||||
|
`smallwebwaf_country_list_refusals_total`, by `country`;
|
||||||
|
- `smallwebwaf_asn_requests_total`, `smallwebwaf_asn_request_bytes_total` and
|
||||||
|
`smallwebwaf_asn_response_bytes_total`, by `asn`.
|
||||||
|
|
||||||
|
## Lookups
|
||||||
|
|
||||||
|
- `smallwebwaf_geojs_requests_total` and `smallwebwaf_geojs_failures_total`;
|
||||||
|
- `smallwebwaf_geojs_unanswered_total`: the requests that needed an answer and
|
||||||
|
went on without it;
|
||||||
|
- with the file source,
|
||||||
|
`smallwebwaf_lookup_database_last_read_timestamp_seconds` and
|
||||||
|
`smallwebwaf_lookup_database_read_failures_total`.
|
||||||
|
|
||||||
|
## Reputation sources
|
||||||
|
|
||||||
|
By `source`: each list's URL, each zone, and `abuseipdb`:
|
||||||
|
|
||||||
|
- `smallwebwaf_reputation_hits_total`, `smallwebwaf_reputation_queries_total`
|
||||||
|
and `smallwebwaf_reputation_failures_total`;
|
||||||
|
- `smallwebwaf_reputation_last_fetch_timestamp_seconds`, for a list;
|
||||||
|
- `smallwebwaf_reputation_daily_budget_remaining`, for AbuseIPDB.
|
||||||
|
|
||||||
|
## State files
|
||||||
|
|
||||||
|
By `file`:
|
||||||
|
|
||||||
|
- `smallwebwaf_state_file_writes_total`,
|
||||||
|
`smallwebwaf_state_file_write_failures_total`,
|
||||||
|
`smallwebwaf_state_file_last_write_timestamp_seconds` and
|
||||||
|
`smallwebwaf_state_file_size_bytes`;
|
||||||
|
- `smallwebwaf_state_file_edits_taken_in_total` and
|
||||||
|
`smallwebwaf_state_file_edits_set_aside_total`;
|
||||||
|
- `smallwebwaf_tracked_clients`: the clients in the table.
|
||||||
|
|
||||||
|
## Remote log, alerts and the process
|
||||||
|
|
||||||
|
- while a syslog server is set, `smallwebwaf_remote_log_lines_sent_total`,
|
||||||
|
`smallwebwaf_remote_log_lines_dropped_total` and
|
||||||
|
`smallwebwaf_remote_log_buffer_depth`;
|
||||||
|
- by `destination`, `smallwebwaf_alerts_sent_total`,
|
||||||
|
`smallwebwaf_alerts_failed_total`, `smallwebwaf_alerts_suppressed_total` and
|
||||||
|
`smallwebwaf_alerts_dropped_total`;
|
||||||
|
- Go's own `go_` metrics and the process's `process_` metrics.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# The request log's fields
|
||||||
|
|
||||||
|
`smallwebwaf` writes one JSON object per line on stdout for every request. The
|
||||||
|
requirements are under "Request log" in [`SPEC.md`](../SPEC.md); this file lists
|
||||||
|
the fields. A field that does not apply to a request is left out.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```
|
||||||
|
{"type":"request","time":"2026-10-03T12:00:00.123Z","instance":"fsn1app1/gitea","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"response_content_type":"text/html; charset=utf-8","upstream_status":200,"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"duration_total":3.217,"duration_checks":0.041,"duration_upstream_connect":0.052,"duration_upstream_first_byte":2.874,"duration_upstream_total":3.104}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Standard web log fields
|
||||||
|
|
||||||
|
- `time`: when the request arrived, RFC 3339 with milliseconds, in UTC;
|
||||||
|
- `instance`: `SWWAF_INSTANCE_NAME`;
|
||||||
|
- `client_ip`, `method`, `scheme`, `host`, `path`, `query`, `protocol`;
|
||||||
|
- `status`: what the client was sent, `0` if nothing;
|
||||||
|
- `request_bytes` and `response_bytes`: body bytes each way;
|
||||||
|
- `referer` and `user_agent`.
|
||||||
|
|
||||||
|
## Request detail
|
||||||
|
|
||||||
|
- `request_id`: the `X-Request-ID` a trusted proxy sent, or a new random one of
|
||||||
|
26 letters and digits. It goes to the app in `X-Request-ID`.
|
||||||
|
- `peer_ip`: the TCP peer, normally traefik.
|
||||||
|
- `forwarded_for`: the `X-Forwarded-For` header as received.
|
||||||
|
- `client_group`: 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 looked up. Each is empty
|
||||||
|
when:
|
||||||
|
- lookups are off;
|
||||||
|
- the client is not looked up;
|
||||||
|
- the source cannot place the client;
|
||||||
|
- GeoJS had not answered by the time the request went on.
|
||||||
|
- `content_type` and `content_length`: the request's own.
|
||||||
|
- `request_headers`: the headers listed in `SWWAF_LOG_REQUEST_HEADERS`, by name
|
||||||
|
in lower case.
|
||||||
|
- `has_authorization` and `has_cookie`: true when the request had those headers.
|
||||||
|
Their values are never logged.
|
||||||
|
- `websocket`: true when the app switched the connection to another protocol.
|
||||||
|
|
||||||
|
## Response detail
|
||||||
|
|
||||||
|
- `upstream_status`: what the app answered. It differs from `status` when
|
||||||
|
`smallwebwaf` answered itself.
|
||||||
|
- `response_content_type`, `cache_control` and `location`: the answer's headers.
|
||||||
|
- `aborted`: true when the client went away early.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- `action`: what was done:
|
||||||
|
- `forward`: passed to the app;
|
||||||
|
- `denied`: refused for `SWWAF_DENY_NETS`, or for a reputation source with
|
||||||
|
the action `deny`;
|
||||||
|
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a
|
||||||
|
CrowdSec decision, each of which bans the client;
|
||||||
|
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
|
||||||
|
refused by that check;
|
||||||
|
- `too_large` and `timed_out`: a size or time limit was passed;
|
||||||
|
- `upstream_error`: the app could not be reached, or its answer broke off;
|
||||||
|
- `admin`: answered at one of `smallwebwaf`'s own endpoints.
|
||||||
|
- `would_action`: in `observe` mode, what `enforce` mode would have done with a
|
||||||
|
request it would have refused. `action` then names what was done, normally
|
||||||
|
`forward`.
|
||||||
|
- `limit_percent` and `limit_percent_setting`: the client's percentage of the
|
||||||
|
rate limits and the setting that lowered it, when one did. `bytes_percent` and
|
||||||
|
`bytes_percent_setting` do the same for the byte limits.
|
||||||
|
- `counts`: the client's requests in the minute, hour and day as rate limiting
|
||||||
|
counts them, this request included, as `minute`, `hour` and `day`; its bytes
|
||||||
|
as `minute_bytes`, `hour_bytes` and `day_bytes`. A count can have a fraction,
|
||||||
|
since each window is counted in two buckets with the earlier one weighted.
|
||||||
|
`counts` is left out when rate limiting did not count the request.
|
||||||
|
- `limit_hit`: the limit broken: `minute`, `hour` or `day` for a rate limit;
|
||||||
|
`minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit; or
|
||||||
|
`error_burst`. A request whose bytes broke a limit was passed on whole, so its
|
||||||
|
`action` stays `forward`.
|
||||||
|
- `rule_ids`: the rule file rules that matched, in order, up to the one that
|
||||||
|
refused.
|
||||||
|
- `waf_score` and `waf_rule_ids`: the Core Rule Set's score and the rules that
|
||||||
|
matched, for a request it inspected.
|
||||||
|
- `reputation`: the sources the client is on, in this order:
|
||||||
|
- blocklist URLs;
|
||||||
|
- the CrowdSec decision list's URL;
|
||||||
|
- DNS blocklist zones;
|
||||||
|
- `abuseipdb`.
|
||||||
|
- `offence`: the kind of offence recorded, when one was.
|
||||||
|
- `ban_expires`: when the client's ban ends, for a request that made one or was
|
||||||
|
refused under one: a time, or `permanent`.
|
||||||
|
|
||||||
|
## Timings
|
||||||
|
|
||||||
|
All in milliseconds:
|
||||||
|
|
||||||
|
- `duration_total`: from the request's headers being read to its log line;
|
||||||
|
- `duration_checks`: to the end of the checks, before forwarding;
|
||||||
|
- `duration_waf`: the part of that the Core Rule Set took;
|
||||||
|
- `duration_upstream_connect`: from the hand-over to the app until there was a
|
||||||
|
connection;
|
||||||
|
- `duration_upstream_first_byte`: until the first byte of the answer;
|
||||||
|
- `duration_upstream_total`: until the end of the answer.
|
||||||
|
|
||||||
|
## Every line
|
||||||
|
|
||||||
|
Every line has `type`, the standard web log fields, `request_id`, `peer_ip`,
|
||||||
|
`client_group`, `asn`, `as_name`, `country`, `action` and `duration_total`. The
|
||||||
|
process's own messages share the stream with `"type":"process"`; request lines
|
||||||
|
carry `"type":"request"`.
|
||||||
|
|
||||||
|
## Sending the log to a syslog server
|
||||||
|
|
||||||
|
While `SWWAF_LOG_REMOTE_URL` is set, every line on stdout is also sent to that
|
||||||
|
server, as the message of an RFC 5424 syslog record. Over UDP each record is one
|
||||||
|
datagram. Over TCP and TLS each record follows its length in bytes and a space.
|
||||||
|
stdout is unchanged. The record carries:
|
||||||
|
|
||||||
|
- the facility from `SWWAF_LOG_REMOTE_FACILITY`;
|
||||||
|
- the severity informational;
|
||||||
|
- the time;
|
||||||
|
- the host's name;
|
||||||
|
- the app name from `SWWAF_LOG_REMOTE_APP_NAME`.
|
||||||
|
|
||||||
|
Lines wait in a buffer of `SWWAF_LOG_REMOTE_BUFFER` lines, so a server that is
|
||||||
|
slow or down never holds up a request. When the buffer is full, the oldest line
|
||||||
|
is dropped. A failed connection is tried again a second later, and twice as long
|
||||||
|
after each further failure, up to a minute. A line too long for one UDP datagram
|
||||||
|
is dropped alone. As `smallwebwaf` stops, it spends at most two seconds sending
|
||||||
|
the lines still waiting, and drops the rest. stdout has carried them.
|
||||||
Reference in New Issue
Block a user