Author SHA1 Message Date
sneak c6ab946c86 Read every setting through viper and rename the settings (closes #211)
check / check (push) Waiting to run
Every setting of waf and rproxy is read through viper, in place of the
os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and
AllowEmptyEnv on, so a setting that is set but empty reads as set. A
setting may also be given as a file, the _FILE form, which rproxy's
settings now have too; rproxy still writes nothing to disk.

The settings are renamed: waf's take the prefix SWW_WAF_, rproxy's take
SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's
settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_.

Model: deepseek-v4-pro (first version); opus-5-5 (rework)
2026-11-11 11:18:21 +00:00
clawbot b6aa2cbc4b Core Rule Set: Forgejo's ordinary requests pass by default (closes #342)
check / check (push) Waiting to run
Forgejo 17.0.1's ordinary traffic, recorded in `testdata/forgejo-requests.txt` (pages signed in and out, admin pages, the API with a token, git clone and push, the generic and container registries, uploads), passes at the default settings. What Forgejo sends and gitea does not: its flash cookie `flash` joins the cookies left out, and in the new `forgejo.conf` `q` on its wiki search skips five rules. In `gitea.conf`, the API's package search joins the name searches, and `q` there also skips 932250, except on user and organisation pages. `README.md` lists Forgejo; `SPEC.md` lists the changes.

- Still refused, each tracked: `ls -la` on user and organisation pages; file and branch names in bodies while bodies are read; Forgejo's searches at a branch or tag, of releases and of job logs; other search fields not yet recorded.

Model: opus-5-5
2026-11-11 12:12:50 +01:00
clawbot 697c2225d9 The image creates SWW_STATE_DIR when it is missing (closes #337)
check / check (push) Waiting to run
Before `waf` starts, the image's `run` script now makes `SWW_STATE_DIR` if it is missing (`mkdir -p`), gives it to `sww:sww` and sets its mode to `0700`, as it already does for the cache directory's owner. A value that is not an absolute path, or an unreadable `SWW_STATE_DIR_FILE`, still changes nothing, and `waf` stops naming the setting. The `Dockerfile` comment, `docs/image.md`, `docs/settings.md` and `SPEC.md` say the image makes the directory. `script/example-app` checks that a missing `SWW_STATE_DIR` is made, owned by `sww` with mode `0700`.

- `chmod 0700` also applies to an existing directory, a mounted volume included, at every start of `waf`.

Model: opus-5-5
2026-11-11 11:33:08 +01:00
clawbot b93bad5842 Answer-time tests check the answer, not wall-clock milliseconds (closes #316)
check / check (push) Waiting to run
Tests only. `TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs` and `TestHangingRoutewatchNeverDelaysAndFailsTheQuery` run in a `synctest` bubble, whose clock load cannot move, instead of comparing wall-clock durations with fixed bounds. The first checks the answer comes after exactly `SWW_LOOKUP_TIMEOUT`, and that the DNS blocklist's query, held by a stand-in resolver, had not failed when it came. The second requires `no_answer_yet` after exactly zero time. The other wall-clock checks in the tests were already in bubbles or are bounds load cannot break.

- The held DNS query still times out on the real clock after two seconds; a stall that long between question and check fails the test.

Model: opus-5-5
2026-11-11 10:56:51 +01:00
clawbot 1748a670f9 sww tests: wait for every starting line as long as the start takes (closes #331)
check / check (push) Waiting to run
Tests only, in `internal/sww`. Every wait for the `starting` line now uses `output.lineBeforeExit`, which waits until the line is written or sww exits, so a slow Core Rule Set compile on a loaded host no longer fails these tests. `TestMainRunsWAF` and `startRProxyProcess` start sww through `startProcess`, whose cleanup kills sww as the test ends and waits for it to exit; `startRProxyProcess` returns a stop function that sends SIGTERM and fails the test unless sww exits with status 0 within `waitLimit`.

Model: opus-5-5
2026-11-11 10:56:47 +01:00
clawbot 83525b5e73 Caches of outside APIs live in /var/cache/sww (closes #314)
check / check (push) Waiting to run
Every cache of answers from an outside service has its own file in `SWW_CACHE_DIR` (default `/var/cache/sww`): `lookups.json` (GeoJS), `lists.json` (lists fetched by URL, CrowdSec's decisions included), `dnsbl.json`, `abuseipdb-scores.json` and `routewatch.json` (at most 100,000 answers, each kept an hour). `reputation.json` is gone; the AbuseIPDB checks spent today stay in `SWW_STATE_DIR` as `abuseipdb-checks-spent.json`, since losing them would spend the daily budget twice.

A cache file never stops the start: one that does not parse is set aside as `<name>.bad` with a `file_error` alert. An unwritable `SWW_CACHE_DIR` stops the start. The `run` script creates the cache directory and gives it to `sww`. The cache directory needs no volume and can be left out of backups.

Model: opus-5-5
2026-11-11 10:56:43 +01:00
sneak 6a59011dee rproxy: cut off an answer that stalls after arriving before the whole request (closes #345)
check / check (push) Waiting to run
When the app's answer arrived before the app had been sent the whole
request, SWRP_MAX_RESPONSE_TIME_MS had no timer: only the end of the
request started one. If the app then stalled, only a write could fail at
the deadline. With nothing written, the connection stayed open until the
request time limit.

modifyResponse now starts the response timer when it ends the request.
Once the timer cuts off the app's answer, nothing is left to flush, so the
last flush in finish succeeded and Go's server read the rest of the body.
finish now also ends that reading at once after a refusal of an answer
already passed on.

Model: opus-5-5
2026-11-11 08:45:51 +00:00
sneak e3c5b4ecf8 rproxy tests: a cut-off answer closes the connection, also after its first part reached the visitor
check / check (push) Waiting to run
The app now sends its late parts only once rproxy has ended the request to
it, not after a sleep timed against the limit. With the body in one part it
stalls after its headers; with two parts, after the first part, which
reaches the visitor. Both cases fail on the current code: nothing cuts the
answer off, and the connection stays open. The 2-second limit lets rproxy
pass on a part sent at once, even on a busy host.

A new test, which passes on the current code, has every flush fail with no
refusal, so that only the failed-flush check in finish ends the reading of
the visitor's body.

Model: opus-5-5
2026-11-11 08:45:51 +00:00
clawbot 6e370b346b rproxy: remove forwarded headers spelt with underscores from an untrusted peer (closes #334)
check / check (push) Waiting to run
For a peer outside `SWRP_TRUSTED_PROXIES`, `rproxy` now reads each header name with `_` as `-` and in any letter case, and removes the header when that name starts with `X-Forwarded-` or is `Forwarded` or `X-Real-IP`. Before, `X_Forwarded_Ssl: on` or `X_Real_IP` from a client connecting directly reached the app unchanged, and an app behind CGI or FastCGI reads them as the dash-spelt headers. A trusted peer's headers pass on as before. `SPEC.md` says so.

Model: opus-5-5
2026-11-11 08:29:59 +01:00
sneak eba697af48 rproxy: close the connection when a short answer is cut off (closes #318)
check / check (push) Waiting to run
When SWRP_MAX_RESPONSE_TIME_MS ran out before the last of a short answer
was written, the flush failed. If the visitor had stopped short of its
announced body, Go's server then read the rest until
SWRP_MAX_REQUEST_TIME_MS ran out, keeping the connection open with the
answer cut off.

finish now flushes once more, and if that fails at its deadline, it ends
the reading of the body at once. ReverseProxy ignores the failed flush when
the app's answer came in one read, and panics when it was still waiting for
more; finish runs after both. Go's server never reuses a connection after a
failed write, so it closes it.

Model: opus-5-5
2026-11-11 07:54:10 +01:00
sneak b9cfbc7fcb rproxy tests: a cut-off answer must close the connection, and the stop-short tests no longer race the clock
A new test fails on the current code. The visitor stops a little short of
its announced body, and the response time limit runs out before rproxy can
write the app's short body. The connection then stays open until the
request time limit instead of closing. The app sends that body in one part,
and in two; with two, ReverseProxy panics instead of returning.

The test that failed under load used a 300 ms response time limit, which a
busy host can use up between the app's headers and its body. It and the
test that answers a visitor that stops sending now use limits far longer
than the test's 10-second read deadline.

Model: opus-5-5
2026-11-11 07:54:10 +01:00
clawbot f077dd7fd0 Say that rproxy passes a refused question's 400 to the visitor (closes #326)
check / check (push) Waiting to run
The comment above `readQuestion` in `internal/waf/waf.go` said `rproxy` treats a refused question (`400`, no disposition) as no answer. It passes the `400` to the visitor, and `SWRP_DEFAULT_DISPOSITION` does not apply, as `SPEC.md` and `internal/rproxy/waf.go` say. Comment only.

Model: opus-5-5
2026-11-11 07:50:59 +01:00
clawbot 81ec6f9bf5 Request log: write & < > as sent (closes #327)
check / check (push) Waiting to run
Request lines of `waf` and `rproxy` now hold `&`, `<` and `>` as sent, where `json.Marshal` wrote `\u0026`, `\u003c` and `\u003e`, and are still valid JSON. `writeJSONLine` encodes with a `json.Encoder` that has HTML escaping off, and still writes each line in one write. This follows the `AGENTS.md` rule that the request log records query strings exactly as received, and lets `grep` find a logged query string.

Alert webhooks and the report `rproxy` posts to `waf` are unchanged: neither is a request line, and both are read only by a JSON parser.

Model: opus-5-5
2026-11-11 07:50:54 +01:00
clawbot 7b52574e5b rproxy: remove every X-Forwarded- header from an untrusted peer (closes #325)
check / check (push) Waiting to run
For a peer outside `SWRP_TRUSTED_PROXIES`, `rproxy` now removes every header whose name starts with `X-Forwarded-`, in any letter case, before it sets `X-Forwarded-For`, `X-Forwarded-Host` and `X-Forwarded-Proto`; `Forwarded` and `X-Real-IP` are removed as before. Until now only traefik's names were removed, so a client connecting directly could send `X-Forwarded-Ssl: on` or `X-Forwarded-Scheme: https` and make an app take a plain request for a secure one. A trusted peer's headers pass on unchanged. `SPEC.md` says so.

Model: opus-5-5
2026-11-11 07:50:50 +01:00
clawbot f76308add3 rproxy tests: give the 1 MiB answer a 60 s time limit (closes #335)
check / check (push) Waiting to run
Test only. `TestAnAnswerOf1MiBGoesToTheVisitor` read an answer of 1 MiB from `waf` within the default `SWRP_TIMEOUT_MS` of 2 s. On a busy host that time ran out first, `rproxy` applied `SWRP_DEFAULT_DISPOSITION` as it should, and the test failed. It now sets `SWRP_TIMEOUT_MS` to `60000`, as the file's other tests do where that limit is not what they test.

Model: opus-5-5
2026-11-11 07:31:27 +01:00
clawbot 937d956d3a waf: the header limits are the first check (closes #311)
check / check (push) Waiting to run
The header limits (more than 50 cookies or 100 header fields, answered `431`) move from just before the Core Rule Set to the first check after `SWW_ALLOW_NETS`. A request over them causes no lookup, no query to RouteWatch, a DNS blocklist or AbuseIPDB, no rule file match and no Core Rule Set. Requests within the limits go through the same checks in the same order.

For a request over the limits: the rate limits no longer count it; a banned client or one in `SWW_DENY_NETS` gets `431` instead of its refusal; it makes no ban, makes no ban permanent and is not counted in a ban's notes; in `observe` mode it is answered `allow` with no lookup. `SPEC.md` and `README.md` say so.

Model: opus-5-5
2026-11-11 07:13:35 +01:00
134 changed files with 5235 additions and 2928 deletions

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ These are the rules for agents working on this repository.
- Secrets belong in POST data. An app that puts a secret in a query string
is fixed in the app.
- Never propose redacting a query string, Gitea's `?token=` included.
- The default of `SWRP_TRUSTED_PROXIES` is the RFC 1918 set.
- The default of `SWW_RPROXY_TRUSTED_PROXIES` is the RFC 1918 set.
- The set is:
- `10.0.0.0/8`;
- `172.16.0.0/12`;
+4 -4
View File
@@ -221,12 +221,12 @@ RUN groupadd --system --gid 65532 sww \
--gid sww --no-create-home --shell /usr/sbin/nologin \
sww
# The state files' directory, SWW_STATE_DIR by default, where a volume
# is mounted to keep them across deploys. The run script gives it to the
# sww user at each start of waf.
# The state files' directory, SWW_WAF_STATE_DIR by default, where a volume
# is mounted to keep them across deploys. At each start of waf, the run
# script makes SWW_WAF_STATE_DIR if it is missing and gives it to the sww user.
RUN mkdir /var/lib/sww
# The default rule file, in SWW_RULES_DIR by default, where a Dockerfile
# The default rule file, in SWW_WAF_RULES_DIR by default, where a Dockerfile
# built FROM this image can copy rule files of its own beside it.
COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules
+231 -208
View File
@@ -12,7 +12,8 @@ shipped in one image, with two commands that do this together:
- `sww rproxy` is a small reverse proxy in front of one app. traefik routes to
it. It asks `waf` about each request, passes the allowed ones to the app, and
passes the app's answers back. It keeps nothing and never touches disk.
passes the app's answers back. It keeps nothing between requests and writes
nothing to disk.
- `sww waf` decides. It runs the checks and keeps the bans, the counters and
each client's history. One `waf` serves many sites: a client banned through
one site is refused on all of them, and its requests on all of them count
@@ -90,8 +91,9 @@ accepts connections, whether or not `waf` answers; in `waf`'s container, once
The usual setup is three containers on one private network:
- the app;
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND`
names `waf` and `SWRP_UPSTREAM_URL` names the app;
- `rproxy`, in front of the app, which traefik routes to:
`SWW_RPROXY_WAF_BACKEND` names `waf` and `SWW_RPROXY_UPSTREAM_URL` names the
app;
- `waf`, with `SWW_COMMAND=waf` and a volume at `/var/lib/sww` for its state
files.
@@ -100,8 +102,8 @@ Point traefik's labels at port 8080 of `rproxy`
of the app or of `waf`. Anything on that network can reach the app around
`rproxy`, and can ask `waf` questions, so the network holds only what you trust.
Each further app adds its own container and its own `rproxy`, whose
`SWRP_WAF_BACKEND` names the same `waf`. The "Smallest setup" example below
shows this shape.
`SWW_RPROXY_WAF_BACKEND` names the same `waf`. The "Smallest setup" example
below shows this shape.
### `rproxy` in the app's container
@@ -114,7 +116,7 @@ digest, and adds:
- the packages the app needs, from the nixpkgs in the image;
- a user for the app;
- a `run` script that starts the app listening on `127.0.0.1:8081`, the default
of `SWRP_UPSTREAM_URL`.
of `SWW_RPROXY_UPSTREAM_URL`.
```dockerfile
# The smallwebwaf image, pinned by digest.
@@ -150,8 +152,8 @@ main "$@"
Leave `ENTRYPOINT` and `USER` out of the app's `Dockerfile`. The image's own
entrypoint starts `rproxy` and the app side by side, each as its own user. Run
the app's image with `SWRP_WAF_BACKEND` set. `deploy/example-app` is such an
app, and the "`rproxy` beside the app" example below runs one.
the app's image with `SWW_RPROXY_WAF_BACKEND` set. `deploy/example-app` is such
an app, and the "`rproxy` beside the app" example below runs one.
The app sees the headers traefik sent, with traefik's address added to
`X-Forwarded-For`, from a TCP peer that is `rproxy`. So the app must trust
@@ -170,12 +172,13 @@ does.
- A request to the site through traefik gets the app's answer.
`https://<site>/_sww/healthz` answers `ok`. `waf`'s log lines about the
request have `"action":"forward"`.
- To see a refusal without banning yourself, run `waf` with `SWW_MODE=observe`
and ask for `/.env`. The request reaches the app. `waf`'s log lines have
`"would_action":"temporarily_banned"` and `"rule_ids":["env-file"]`. A ban you
get in `enforce` mode is lifted as "Clients and bans" below describes.
- With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on
`waf`'s network:
- To see a refusal without banning yourself, run `waf` with
`SWW_WAF_MODE=observe` and ask for `/.env`. The request reaches the app.
`waf`'s log lines have `"would_action":"temporarily_banned"` and
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
"Clients and bans" below describes.
- With `SWW_WAF_METRICS_TOKEN` set, this answers with the metrics, from a host
on `waf`'s network:
`curl -H "Authorization: Bearer $TOKEN" http://waf:8080/_sww/metrics`.
- `make example-app` builds the image and an app on it, runs them, and checks
the whole exchange (see "Entrypoints").
@@ -183,18 +186,17 @@ does.
## Configuration
Every setting is an environment variable, read once at start; changing one means
restarting the container. `rproxy` reads only the `SWRP_` settings, and `waf`
only the `SWW_` ones. Every setting but `SWRP_WAF_BACKEND` has a default, chosen
for a service on the open internet. A setting that needs an account, a key, a
token or an alert destination stays off until you give one.
restarting the container. `rproxy` reads only the `SWW_RPROXY_` settings, and
`waf` only the `SWW_WAF_` ones. Every setting but `SWW_RPROXY_WAF_BACKEND` has a
default, chosen for a service on the open internet. A setting that needs an
account, a key, a token or an alert destination stays off until you give one.
- Any `SWW_` setting can be given as a file instead, for secrets and long lists:
`SWW_ADMIN_TOKEN_FILE` names the file that holds `SWW_ADMIN_TOKEN`. The file's
contents are the value, less one newline at the end. Setting both forms stops
the start. `SWW_LOG_REMOTE_TLS_CA_FILE` and
`SWW_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have no
`_FILE` form. `SWW_COMMAND` cannot be given as a file. No `SWRP_` setting has
one, since `rproxy` never touches disk.
- Any `SWW_WAF_` or `SWW_RPROXY_` setting can be given as a file instead, for
secrets and long lists: `SWW_WAF_ADMIN_TOKEN_FILE` names the file that holds
`SWW_WAF_ADMIN_TOKEN`. The file's contents are the value, less one newline at
the end. Setting both forms stops the start. `SWW_WAF_LOG_REMOTE_TLS_CA_FILE`
and `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have
no `_FILE` form. `SWW_COMMAND` cannot be given as a file.
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`.
- A size is a number of bytes with an optional `K`, `M` or `G`, in powers of
1024: `512K`, `100M`, `5G`.
@@ -210,131 +212,133 @@ token or an alert destination stays off until you give one.
setting takes it.
- The byte limits and the byte anomaly thresholds count only body bytes: the
app's response bodies, the visitors' request bodies, or both, as
`SWW_BYTES_COUNT` says. Headers are never counted. What the visitor sent over
a WebSocket counts as request bytes, and what the app sent as response bytes.
`SWW_WAF_BYTES_COUNT` says. Headers are never counted. What the visitor sent
over a WebSocket counts as request bytes, and what the app sent as response
bytes.
- An invalid setting stops the process at start with one log line naming the
variable and what is wrong with the value. The settings in effect are logged
on one line at start, with tokens, keys and webhook paths shown as `********`.
Every setting the code reads, with its default:
| Variable | Default | What it does |
| ---------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `waf`. |
| `SWRP_WAF_BACKEND` | required | `waf`'s URL, such as `http://waf:8080`. |
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `waf`'s answer to a question and to a report, in milliseconds. |
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `waf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. |
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. |
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
| `SWW_LISTEN_ADDR` | `:8080` | Where `waf` listens; leave the host part empty. |
| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `waf` logs, and in every alert and metric, such as `fsn1/waf`. |
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
| `SWW_MAX_BANS` | `5000` | The most bans `waf` keeps of its own; bans an admin made do not count. |
| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. |
| `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; the next request is refused and bans the client. |
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. |
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. |
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. |
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. |
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. |
| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A client that breaks a limit again within this time after its last such ban ended gets a ban three times as long. |
| `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
| `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
| `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
| `SWW_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. |
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT` (the lowest wins). |
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
| `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
| `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
| `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
| `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
| `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
| `SWW_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
| `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
| `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
| `SWW_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
| `SWW_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
| `SWW_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
| `SWW_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
| `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
| `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
| `SWW_LOG_LEVEL` | `info` | The least severe of `waf`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
| `SWW_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
| `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `waf` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
| `SWW_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
| `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
| `SWW_LOG_REMOTE_APP_NAME` | `SWW_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
| `SWW_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
| `SWW_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
| `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
| `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
| `SWW_ALERT_EVENTS` | `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in a minute, a size. |
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in an hour, a size. |
| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. |
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. |
| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. |
| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. |
| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. |
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. |
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. |
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. |
| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. |
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. |
| Variable | Default | What it does |
| -------------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `waf`. |
| `SWW_RPROXY_WAF_BACKEND` | required | `waf`'s URL, such as `http://waf:8080`. |
| `SWW_RPROXY_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
| `SWW_RPROXY_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
| `SWW_RPROXY_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
| `SWW_RPROXY_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `waf`'s answer to a question and to a report, in milliseconds. |
| `SWW_RPROXY_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `waf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
| `SWW_RPROXY_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
| `SWW_RPROXY_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. |
| `SWW_RPROXY_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. |
| `SWW_RPROXY_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
| `SWW_WAF_LISTEN_ADDR` | `:8080` | Where `waf` listens; leave the host part empty. |
| `SWW_WAF_INSTANCE_NAME` | the container's host name | The name in every line `waf` logs, and in every alert and metric, such as `fsn1/waf`. |
| `SWW_WAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
| `SWW_WAF_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
| `SWW_WAF_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
| `SWW_WAF_MAX_BANS` | `5000` | The most bans `waf` keeps of its own; bans an admin made do not count. |
| `SWW_WAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
| `SWW_WAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
| `SWW_WAF_DENY_NETS` | empty | Netblocks whose clients are refused. |
| `SWW_WAF_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; the next request is refused and bans the client. |
| `SWW_WAF_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. |
| `SWW_WAF_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. |
| `SWW_WAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. |
| `SWW_WAF_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_WAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_WAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWW_WAF_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. |
| `SWW_WAF_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. |
| `SWW_WAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
| `SWW_WAF_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
| `SWW_WAF_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A client that breaks a limit again within this time after its last such ban ended gets a ban three times as long. |
| `SWW_WAF_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
| `SWW_WAF_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
| `SWW_WAF_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
| `SWW_WAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
| `SWW_WAF_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
| `SWW_WAF_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
| `SWW_WAF_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
| `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
| `SWW_WAF_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
| `SWW_WAF_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
| `SWW_WAF_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
| `SWW_WAF_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. |
| `SWW_WAF_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
| `SWW_WAF_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
| `SWW_WAF_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
| `SWW_WAF_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_WAF_TAG_LIMIT_PERCENT` (the lowest wins). |
| `SWW_WAF_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
| `SWW_WAF_RULES_ENABLED` | `true` | `false` reads no rule file. |
| `SWW_WAF_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
| `SWW_WAF_CORE_RULE_SET_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
| `SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
| `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
| `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
| `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
| `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
| `SWW_WAF_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
| `SWW_WAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
| `SWW_WAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
| `SWW_WAF_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
| `SWW_WAF_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
| `SWW_WAF_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
| `SWW_WAF_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
| `SWW_WAF_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
| `SWW_WAF_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
| `SWW_WAF_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
| `SWW_WAF_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
| `SWW_WAF_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
| `SWW_WAF_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
| `SWW_WAF_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
| `SWW_WAF_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
| `SWW_WAF_STATE_COUNTER_INTERVAL` | `15m` | How often every state and cache file is written. |
| `SWW_WAF_CACHE_DIR` | `/var/cache/sww` | The directory of the cache files, an absolute path. |
| `SWW_WAF_LOG_LEVEL` | `info` | The least severe of `waf`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
| `SWW_WAF_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
| `SWW_WAF_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `waf` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
| `SWW_WAF_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
| `SWW_WAF_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
| `SWW_WAF_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
| `SWW_WAF_LOG_REMOTE_APP_NAME` | `SWW_WAF_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
| `SWW_WAF_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
| `SWW_WAF_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
| `SWW_WAF_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
| `SWW_WAF_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
| `SWW_WAF_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
| `SWW_WAF_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
| `SWW_WAF_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
| `SWW_WAF_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
| `SWW_WAF_ALERT_EVENTS` | `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
| `SWW_WAF_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
| `SWW_WAF_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
| `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
| `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
| `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one client in a minute, a size. |
| `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one client in an hour, a size. |
| `SWW_WAF_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
| `SWW_WAF_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
| `SWW_WAF_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. |
| `SWW_WAF_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. |
| `SWW_WAF_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_WAF_ANOMALY_NET_` thresholds count, from 0 to 32. |
| `SWW_WAF_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_WAF_ANOMALY_NET_` thresholds count, from 0 to 128. |
| `SWW_WAF_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
| `SWW_WAF_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
| `SWW_WAF_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. |
| `SWW_WAF_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. |
| `SWW_WAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
| `SWW_WAF_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
| `SWW_WAF_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. |
| `SWW_WAF_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. |
| `SWW_WAF_WATCH_NETS` | empty | Named netblocks the `SWW_WAF_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
| `SWW_WAF_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
| `SWW_WAF_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
| `SWW_WAF_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. |
| `SWW_WAF_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_WAF_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. |
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
full, and the limits that are fixed rather than settings.
@@ -361,8 +365,8 @@ services:
rproxy:
image: smallwebwaf
environment:
SWRP_WAF_BACKEND: http://waf:8080
SWRP_UPSTREAM_URL: http://app:8081
SWW_RPROXY_WAF_BACKEND: http://waf:8080
SWW_RPROXY_UPSTREAM_URL: http://app:8081
ports:
- "8080:8080"
@@ -397,8 +401,8 @@ services:
rproxy:
image: smallwebwaf
environment:
SWRP_WAF_BACKEND: http://waf:8080
SWRP_UPSTREAM_URL: http://app:8081
SWW_RPROXY_WAF_BACKEND: http://waf:8080
SWW_RPROXY_UPSTREAM_URL: http://app:8081
ports:
- "8080:8080"
@@ -407,22 +411,22 @@ services:
environment:
SWW_COMMAND: waf
# Monitoring skips every check.
SWW_ALLOW_NETS: 192.0.2.10
SWW_WAF_ALLOW_NETS: 192.0.2.10
# The office and the automation host are never rate limited.
SWW_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
SWW_WAF_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
# A netblock that sends nothing wanted.
SWW_DENY_NETS: 198.51.100.128/25
SWW_WAF_DENY_NETS: 198.51.100.128/25
# Tighter limits for everyone else: a small static site.
SWW_RATE_LIMIT_PER_MINUTE: "60"
SWW_RATE_LIMIT_PER_HOUR: "600"
SWW_RATE_LIMIT_PER_DAY: "3000"
SWW_WAF_RATE_LIMIT_PER_MINUTE: "60"
SWW_WAF_RATE_LIMIT_PER_HOUR: "600"
SWW_WAF_RATE_LIMIT_PER_DAY: "3000"
# Hosting networks and one country get a share of those limits.
SWW_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=CN:25
SWW_WAF_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=CN:25
# Paths only scanners ask for; one request bans the client.
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
SWW_WAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php
# Answer a refused client with 429 rather than 403.
SWW_BAN_RESPONSE: "429"
SWW_LIMIT_BAN_DURATION: 30m
SWW_WAF_BAN_RESPONSE: "429"
SWW_WAF_LIMIT_BAN_DURATION: 30m
volumes:
- waf-state:/var/lib/sww
@@ -448,9 +452,9 @@ services:
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
build: ./app
environment:
SWRP_WAF_BACKEND: http://waf:8080
SWW_RPROXY_WAF_BACKEND: http://waf:8080
# Answer 503 rather than pass a request on while waf is down.
SWRP_DEFAULT_DISPOSITION: deny
SWW_RPROXY_DEFAULT_DISPOSITION: deny
ports:
- "8080:8080"
@@ -458,16 +462,16 @@ services:
image: smallwebwaf
environment:
SWW_COMMAND: waf
SWW_INSTANCE_NAME: fsn1/waf
SWW_WAF_INSTANCE_NAME: fsn1/waf
# Refuse nothing yet; log what enforce mode would have done.
SWW_MODE: observe
SWW_WAF_MODE: observe
# Look addresses up in a local IPinfo Lite database, never GeoJS.
SWW_LOOKUP_SOURCE: file
SWW_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
SWW_WAF_LOOKUP_SOURCE: file
SWW_WAF_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
# Alerts to an ntfy topic, with its token read from a file.
SWW_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
SWW_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
SWW_ALERT_EVENTS: ban,permanent_ban,core_rule_set_match,source_failure,file_error
SWW_WAF_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
SWW_WAF_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
SWW_WAF_ALERT_EVENTS: ban,permanent_ban,core_rule_set_match,source_failure,file_error
volumes:
- waf-state:/var/lib/sww
- /srv/ipinfo:/var/lib/ipinfo:ro
@@ -504,24 +508,25 @@ request reaches the app.
1. The health check, `GET /_sww/healthz`, is answered at once.
2. The visitor's address is worked out from `X-Forwarded-For`, believed only
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `waf` the request as the visitor
sent it, with the first 64 KiB of its body.
from `SWW_RPROXY_TRUSTED_PROXIES`. `rproxy` sends `waf` the request as the
visitor sent it, with the first 64 KiB of its body.
`waf` takes the checks:
3. The tag lists.
4. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
3. The header limits, then the tag lists. A client in `SWW_WAF_ALLOW_NETS` skips
the header limits.
4. The static lists, `SWW_WAF_ALLOW_NETS` and `SWW_WAF_DENY_NETS`.
5. The bans.
6. The lookup of the client's AS number and country.
7. The country list.
8. The reputation sources.
9. The tag refusals of `SWW_TAG_DENY`.
9. The tag refusals of `SWW_WAF_TAG_DENY`.
10. The client's limit percentage, from its tags, its AS number, its country or
a reputation hit.
11. The rate limits.
12. The trap paths.
13. The rule files.
14. The header limits, and then the Core Rule Set.
14. The Core Rule Set.
15. The tag refusals again, so that `exploitsignature` can refuse too.
16. A path under `/_sww/` is answered `404`, so that the app never sees that
prefix.
@@ -531,23 +536,23 @@ request reaches the app.
17. The request is passed to the app, streaming.
18. After the answer, `rproxy` sends `waf` a report with the status and the body
bytes each way. For an allowed request, `waf` counts the bytes
`SWW_BYTES_COUNT` names toward the byte limits, bans the client if they take
it past one, and adds the request to the client's history.
`SWW_WAF_BYTES_COUNT` names toward the byte limits, bans the client if they
take it past one, and adds the request to the client's history.
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A
request over the header limits of step 14 is answered `431`, and bans nobody
either. `rproxy` itself answers `405` to a method it does not pass on, and the
status for a size or time limit, such as `413` for a body too large.
A refused visitor is answered with `SWW_WAF_BAN_RESPONSE`. A `block` rule or a
Core Rule Set match answers `403` and bans nobody, since the Core Rule Set has
false positives; such refusals count toward `SWW_WAF_ERROR_BURST_THRESHOLD`
instead. A request over the header limits of step 3 is answered `431`, and bans
nobody either. `rproxy` itself answers `405` to a method it does not pass on,
and the status for a size or time limit, such as `413` for a body too large.
### Clients and bans
- A client is one IPv4 address or one IPv6 netblock of `SWW_IPV6_GROUP_PREFIX`,
since one abuser usually controls a whole /64.
- A client is one IPv4 address or one IPv6 netblock of
`SWW_WAF_IPV6_GROUP_PREFIX`, since one abuser usually controls a whole /64.
- Each limit is counted over a sliding window with a few integers per client.
The table of clients and the table of bans each have a bound
(`SWW_MAX_TRACKED_CLIENTS`, `SWW_MAX_BANS`), so memory is bounded too.
(`SWW_WAF_MAX_TRACKED_CLIENTS`, `SWW_WAF_MAX_BANS`), so memory is bounded too.
- A ban refuses every request from its netblock until it ends. Requests refused
under a ban count toward no limit. A permanent ban ends only when an admin
lifts it.
@@ -562,24 +567,24 @@ Each request gets tags from what `waf` knows of its client, written in the
`tags` field of its log line:
- `asn=<number>` and `country=<code>`, from the lookup;
- one tag per list of `SWW_TAG_LISTS` that holds the client, such as `torexit`
or `vpn=mullvad`;
- one tag per list of `SWW_WAF_TAG_LISTS` that holds the client, such as
`torexit` or `vpn=mullvad`;
- `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists
the client;
- `exploitsignature`, when a rule file or the Core Rule Set matched the request;
- `asn=<number>` and `prefix=<netblock>`, from a routewatch instance, while
`SWW_ROUTEWATCH_URL` is set.
`SWW_WAF_ROUTEWATCH_URL` is set.
`SWW_TAG_DENY` refuses requests by tag, and `SWW_TAG_LIMIT_PERCENT` lowers a
client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
`SWW_WAF_TAG_DENY` refuses requests by tag, and `SWW_WAF_TAG_LIMIT_PERCENT`
lowers a client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
### Attack detection
- The Core Rule Set reads the method, the URL with its query string, and the
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
front of a code forge, bodies carry what people write and publish, which the
rules take for attacks.
- `smallwebwaf` changes the Core Rule Set in fourteen ways so that ordinary
headers of every request, and no body unless
`SWW_WAF_CORE_RULE_SET_BODY_LIMIT` is set. In front of a code forge, bodies
carry what people write and publish, which the rules take for attacks.
- `smallwebwaf` changes the Core Rule Set in fifteen ways so that ordinary
requests get through to the apps listed below. No setting undoes the changes;
"Attack detection" in [`SPEC.md`](SPEC.md) lists them.
- These apps work out of the box at the default settings, and their recorded
@@ -587,6 +592,9 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
- gitea: git over HTTP and sign-in through Git Credential Manager get
through. On gitea's own paths, its searches, file names and branch names
do too.
- Forgejo: git over HTTP and its container and package registries get
through. On Forgejo's own paths, its searches, file names and branch names
do too.
- WordPress: saving a post from the block editor gets through. On
WordPress's own paths, its searches, names and file names do too.
- Drupal: on Drupal's own paths, the page to return to, its searches and its
@@ -607,21 +615,35 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
- deleting the account, on gitea;
- the admin page, on Vaultwarden, whose apps send a hash of the master
password, not the password itself.
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
fields or paths.
- `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's
own fields or paths.
- Rule files are plain text, one rule to a line, edited by hand and read again
while `waf` runs. The image ships one file of probes no real visitor sends
(see [`docs/rules.md`](docs/rules.md)).
### State files
### State and cache files
`waf` keeps everything in memory, and the five JSON files in `SWW_STATE_DIR`
hold a copy: `bans.json`, `clients.json`, `lookups.json`, `reputation.json` and
`alerts.json`. Each is written whole, to a temporary file renamed over the real
one, so a crash leaves a complete file. `waf` notices a file edited on disk and
takes the edit in; an edit that does not parse is set aside as `<name>.bad`, and
never stops the running process (see
[`docs/state-files.md`](docs/state-files.md)).
`waf` keeps everything in memory, and JSON files hold a copy:
- The state files, in `SWW_WAF_STATE_DIR`, hold what cannot be fetched again:
- `bans.json`;
- `clients.json`;
- `abuseipdb-checks-spent.json`;
- `alerts.json`.
- The cache files, in `SWW_WAF_CACHE_DIR`, hold answers from outside services:
- `lookups.json`, from GeoJS;
- `lists.json`, the lists fetched by URL;
- `dnsbl.json`, from the DNS blocklists;
- `abuseipdb-scores.json`, from AbuseIPDB;
- `routewatch.json`, from routewatch.
Back up the state directory. The cache directory can be left out of backups: a
lost cache file starts empty and fills again.
Each file is written whole, to a temporary file renamed over the real one, so a
crash leaves a complete file. `waf` notices a file edited on disk and takes the
edit in. An edit that does not parse is set aside as `<name>.bad`, and never
stops the running process (see [`docs/state-files.md`](docs/state-files.md)).
### The image and its processes
@@ -632,7 +654,7 @@ never stops the running process (see
- runit, a service supervisor, runs `sww` as the `sww` user (uid and gid 65532)
and starts it again when it exits. The `run` script of `waf` gives the state
directory to the `sww` user at each start, so a volume needs no change of
owner.
owner. It makes the cache directory for that user too.
- `rproxy` and `waf` use no token between them. `waf`'s own endpoints, under
`/_sww/`, are for your admin tools and your metrics collector, at its own
address on the private network; nothing routes the internet there.
@@ -657,8 +679,8 @@ never stops the running process (see
`internal/routewatch`, `internal/anomaly` and `internal/alerts`: one check or
source each.
- `internal/state`, `internal/requestlog`, `internal/remotelog` and
`internal/metrics`: the state files, the log lines, the syslog sender and the
metrics.
`internal/metrics`: the state and cache files, the log lines, the syslog
sender and the metrics.
## Rationale
@@ -738,6 +760,7 @@ on the host.
`http://127.0.0.1:8081`;
- `waf` listens on port 8090;
- its state files go in `bin/state`;
- its cache files go in `bin/cache`;
- its rule files are those of `share/rules.d`.
- `script/example-app`: builds the image and the example app
(`deploy/example-app`) on it, runs the app with `rproxy` in its container and
@@ -774,8 +797,8 @@ on the host.
- [`docs/alerts.md`](docs/alerts.md): the alert events and what each destination
gets.
- [`docs/metrics.md`](docs/metrics.md): the metrics.
- [`docs/state-files.md`](docs/state-files.md): the state files and what each
holds.
- [`docs/state-files.md`](docs/state-files.md): the state and cache files and
what each holds.
- [`docs/image.md`](docs/image.md): what the image holds and how it is pinned.
## License
+543 -468
View File
File diff suppressed because it is too large. Load diff
+6 -6
View File
@@ -5,16 +5,16 @@ answers them at its own address, on the private network. "Admin endpoints" in
[`SPEC.md`](../SPEC.md) says how their tokens work.
- `GET /_sww/healthz`: `200` and `ok`, to anyone.
- `GET /_sww/metrics`: the metrics, for `SWW_METRICS_TOKEN` (see
- `GET /_sww/metrics`: the metrics, for `SWW_WAF_METRICS_TOKEN` (see
[`metrics.md`](metrics.md)).
- `GET /_sww/bans`: every ban held, under `bans`, each as `bans.json` lists it.
Needs `SWW_ADMIN_TOKEN`, as do the three below.
Needs `SWW_WAF_ADMIN_TOKEN`, as do the three below.
- `POST /_sww/bans`: bans a netblock. The body is a JSON object of `netblock`,
`duration` and, if you like, `reason`. `netblock` is a netblock such as
`203.0.113.0/24`, or a client's address, which bans its IPv4 netblock
(`SWW_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such
as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is
wrong:
(`SWW_WAF_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration
such as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying
what is wrong:
- one that is not such an object, or has another field;
- one longer than 4 KiB;
- one that names an IPv4-mapped netblock, or an address with a zone.
@@ -38,7 +38,7 @@ answered `404`, so that the app never sees that prefix (see "`rproxy`" in
## Examples
With `SWW_ADMIN_TOKEN` set, the token in `$TOKEN`, and `waf` reached as
With `SWW_WAF_ADMIN_TOKEN` set, the token in `$TOKEN`, and `waf` reached as
`waf:8080` on its network:
```sh
+6 -5
View File
@@ -23,7 +23,8 @@ back or dropped.
- 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 cache file that does not parse, at start or after an edit;
- a state file or cache file that cannot be written;
- a replacement lookup database that cannot be read.
- `summary`: the hourly summary of alerts held back.
@@ -77,8 +78,8 @@ on one line:
}
```
- `instance`: `SWW_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
one of the events above.
- `instance`: `SWW_WAF_INSTANCE_NAME`. `time`: when the alert was raised.
`event`: one of the events above.
- `host`: the site of the request that raised the alert, since one `waf` serves
many sites. It is left out of `source_failure`, `file_error` and `summary`.
- `client`: the address whose request raised the alert. Empty for
@@ -121,8 +122,8 @@ 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 `SWW_ALERT_NTFY_TOKEN`
is set;
- `Authorization`: the token, as `Bearer <token>`, while
`SWW_WAF_ALERT_NTFY_TOKEN` is set;
- `Priority` and `Tags`: by event, as listed below.
The priority and tag by event:
+2 -1
View File
@@ -19,7 +19,8 @@ way.
- The service directory `/etc/service/sww`, with the `run` script
`share/sww.run`.
- `/var/lib/sww` for `waf`'s state files, and `/etc/sww/rules.d` with the
default rule file `share/rules.d/00-default.rules`.
default rule file `share/rules.d/00-default.rules`. The `run` script makes
`SWW_WAF_STATE_DIR` when it is missing.
- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `sww healthcheck`.
## Pins
+4 -4
View File
@@ -6,7 +6,7 @@ the `instance` label as `exported_instance` unless the scrape sets
`honor_labels: true`. `rproxy` has no metrics.
Every `sww_*request_bytes_total` and `sww_*response_bytes_total` series counts
body bytes, never headers, whatever `SWW_BYTES_COUNT` says.
body bytes, never headers, whatever `SWW_WAF_BYTES_COUNT` says.
## Traffic
@@ -33,7 +33,7 @@ body bytes, never headers, whatever `SWW_BYTES_COUNT` says.
## Countries and AS numbers
For the `SWW_METRICS_TOP_N` busiest of each, the rest as `other`:
For the `SWW_WAF_METRICS_TOP_N` busiest of each, the rest as `other`:
- `sww_country_requests_total`, `sww_country_request_bytes_total`,
`sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by
@@ -58,9 +58,9 @@ By `source`: each list's URL, each zone, and `abuseipdb`:
- `sww_reputation_last_fetch_timestamp_seconds`, for a list;
- `sww_reputation_daily_budget_remaining`, for AbuseIPDB.
## State files
## State and cache files
By `file`:
By `file`, the name of a state or cache file:
- `sww_state_file_writes_total`, `sww_state_file_write_failures_total`,
`sww_state_file_last_write_timestamp_seconds` and `sww_state_file_size_bytes`;
+17 -15
View File
@@ -43,7 +43,7 @@ Every one of these lines has:
- `time`: when `waf` had read the question's body, RFC 3339 with milliseconds,
in UTC. The `report` line gives the same time as the `decision` line.
- `instance`: `SWW_INSTANCE_NAME`.
- `instance`: `SWW_WAF_INSTANCE_NAME`.
- `client_ip`: the visitor's address, as `rproxy` worked it out.
- `method`, `path` and `query`: the visitor's request.
- `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of
@@ -80,8 +80,8 @@ Every one of these lines has:
- GeoJS had not answered by the time `waf` answered.
- `content_type`: the visitor's `Content-Type`.
- `content_length`: the body length the visitor announced.
- `request_headers`: the headers listed in `SWW_LOG_REQUEST_HEADERS`, by name in
lower case.
- `request_headers`: the headers listed in `SWW_WAF_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.
@@ -94,8 +94,8 @@ Every one of these lines has:
- `action`: what was done:
- `forward`: answered `allow`, so that `rproxy` passes it to the app;
- `client_denied`: refused for `SWW_DENY_NETS`, for a reputation source with
the action `deny`, or for a tag in `SWW_TAG_DENY`;
- `client_denied`: refused for `SWW_WAF_DENY_NETS`, for a reputation source
with the action `deny`, or for a tag in `SWW_WAF_TAG_DENY`;
- `temporarily_banned`: refused under a ban that runs out, or for a `ban`
rule, a trap path or a CrowdSec decision that made such a ban;
- `permanently_banned`: refused under a ban that never runs out, including a
@@ -112,21 +112,23 @@ Every one of these lines has:
is left out when none did.
- `heuristic_figures`: an object of the figures that heuristic acted on. Each
heuristic and its figures:
- `deny_nets`: `netblock`, the entry of `SWW_DENY_NETS` the client is in;
- `deny_nets`: `netblock`, the entry of `SWW_WAF_DENY_NETS` the client is
in;
- `active_ban`: `netblock`, the ban's netblock, and `ban_expires`;
- `countries`: `country`, and `setting`, the country list that refused it;
- `tag_deny`: `tag`, the tag of `SWW_TAG_DENY` the request carries;
- `tag_deny`: `tag`, the tag of `SWW_WAF_TAG_DENY` the request carries;
- `blocklists`: `lists`, the blocklist URLs that list the client;
- `crowdsec`: `scenario`, the decision's scenario, and `expires`, when the
decision ends;
- `dnsbl`: `zones`, the DNS blocklist zones that list the client, their keys
masked;
- `abuseipdb`: `score`, and `min_score`, `SWW_ABUSEIPDB_MIN_SCORE`;
- `abuseipdb`: `score`, and `min_score`, `SWW_WAF_ABUSEIPDB_MIN_SCORE`;
- `rate_limits`: `window`, `count` and `limit`;
- `trap_paths`: `path`, the trap path asked for;
- `rule_files`: `rule_id` and `rule_action`, of the rule that refused it;
- `header_limits`: `fields`, `fields_limit`, `cookies` and `cookies_limit`;
- `core_rule_set`: `score`, and `threshold`, `SWW_WAF_ANOMALY_THRESHOLD`;
- `core_rule_set`: `score`, and `threshold`,
`SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD`;
- `refusal_limit`: `window`, `count` and `limit`, of the error burst;
- `byte_limits`: `window`, `count` and `limit`, the count in bytes.
- `after_answer_heuristic`: the heuristic that banned the client after `waf`
@@ -146,7 +148,7 @@ Every one of these lines has:
holds:
- `minute`, `hour` and `day`: requests;
- `minute_bytes`, `hour_bytes` and `day_bytes`: the body bytes
`SWW_BYTES_COUNT` names, in that window.
`SWW_WAF_BYTES_COUNT` names, in that window.
A count can have a fraction (see "Counting method" in
[`SPEC.md`](../SPEC.md)). The bytes counted for an allowed request and its
@@ -156,7 +158,7 @@ Every one of these lines has:
- `limit_hit`: the limit broken:
- `minute`, `hour` or `day` for a rate limit;
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
- `minute_refusals` for the error burst (`SWW_ERROR_BURST_THRESHOLD`).
- `minute_refusals` for the error burst (`SWW_WAF_ERROR_BURST_THRESHOLD`).
When the bytes counted for a request and its response break a limit, the
request has been passed on whole, so its `action` stays `forward`. Only its
@@ -174,7 +176,7 @@ Every one of these lines has:
- `tags`: the request's tags, sorted, left out when it has none. Each tag and
where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md).
- `routewatch_tags_missing`: why the request got no tags from routewatch,
present only while `SWW_ROUTEWATCH_URL` is set:
present only while `SWW_WAF_ROUTEWATCH_URL` is set:
- `no_answer_yet`: routewatch has not answered about this client yet;
- `request_failed`: a query to routewatch failed within the last minute, and
the process log says why;
@@ -231,13 +233,13 @@ meanings where they differ:
was sooner. It includes the wait for `waf`.
- `duration_response_ms`: milliseconds from then until the end of the answer.
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It
is `waf`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is left out
when:
is `waf`'s answer, or the value of `SWW_RPROXY_DEFAULT_DISPOSITION`. It is
left out when:
- `waf` was not asked, as for the health check;
- the visitor went away while `waf` was being asked;
- `waf`'s answer had no valid `X-Sww-Disposition`;
- `rproxy` could not pass on `waf`'s answer, and answered `502`.
- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave
- `default_disposition`: `true` when `SWW_RPROXY_DEFAULT_DISPOSITION` gave
`disposition`, and otherwise left out.
For example:
+253 -243
View File
@@ -4,9 +4,9 @@ Every setting, its default and the values it takes, in the groups of "Settings"
in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings
that take `off`, the `_FILE` form, and what each group does and why.
The settings that start with `SWW_` are `waf`'s. Those that start with `SWRP_`
are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND` chooses between
the two (see "The image's command" below).
The settings that start with `SWW_WAF_` are `waf`'s. Those that start with
`SWW_RPROXY_` are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND`
chooses between the two (see "The image's command" below).
## Forms of values
@@ -28,96 +28,100 @@ the two (see "The image's command" below).
## Core
- `SWW_LISTEN_ADDR` (default `:8080`): where `waf` listens: `:` and a port, such
as `:9000`. Leave the host out (see "Ports" in [`SPEC.md`](../SPEC.md)).
- `SWW_INSTANCE_NAME` (default: the container's host name): the `instance` name
in each line `waf` logs, and in each alert and metric, such as `fsn1/waf`.
Valid UTF-8. No control character while `SWW_ALERT_NTFY_URL` is set. 1 to 48
printable ASCII characters without a space while `SWW_LOG_REMOTE_URL` is set
and `SWW_LOG_REMOTE_APP_NAME` is not.
- `SWW_MODE` (default `enforce`): `enforce` or `observe`.
- `SWW_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6 client's
group, from 32 to 128.
- `SWW_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
number above zero.
- `SWW_MAX_BANS` (default `5000`): how many of its own bans `waf` keeps, a whole
number above zero.
- `SWW_WAF_LISTEN_ADDR` (default `:8080`): where `waf` listens: `:` and a port,
such as `:9000`. Leave the host out (see "Ports" in [`SPEC.md`](../SPEC.md)).
- `SWW_WAF_INSTANCE_NAME` (default: the container's host name): the `instance`
name in each line `waf` logs, and in each alert and metric, such as
`fsn1/waf`. Valid UTF-8. No control character while `SWW_WAF_ALERT_NTFY_URL`
is set. 1 to 48 printable ASCII characters without a space while
`SWW_WAF_LOG_REMOTE_URL` is set and `SWW_WAF_LOG_REMOTE_APP_NAME` is not.
- `SWW_WAF_MODE` (default `enforce`): `enforce` or `observe`.
- `SWW_WAF_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6
client's group, from 32 to 128.
- `SWW_WAF_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a
whole number above zero.
- `SWW_WAF_MAX_BANS` (default `5000`): how many of its own bans `waf` keeps, a
whole number above zero.
## Tokens
- `SWW_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
- `SWW_WAF_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
`/_sww/clients/<ip>`. At least 32 characters.
- `SWW_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At least
32 characters.
- `SWW_WAF_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At
least 32 characters.
## State files
## State and cache files
- `SWW_STATE_DIR` (default `/var/lib/sww`): the directory of the state files, an
absolute path that exists and can be written.
- `SWW_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
- `SWW_WAF_STATE_DIR` (default `/var/lib/sww`): the directory of the state
files, an absolute path that can be written. The image's `run` script makes
it.
- `SWW_WAF_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
written after a ban is made, lifted or made permanent. A duration.
- `SWW_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
written. A duration.
- `SWW_WAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state and
cache file is written. A duration.
- `SWW_WAF_CACHE_DIR` (default `/var/cache/sww`): the directory of the cache
files, an absolute path that can be written. The image's `run` script makes
it.
## Logging
- `SWW_LOG_LEVEL` (default `info`): the least severe of `waf`'s own messages
- `SWW_WAF_LOG_LEVEL` (default `info`): the least severe of `waf`'s own messages
that are written: `debug`, `info`, `warn` or `error`.
- `SWW_LOG_REQUEST_HEADERS` (default
- `SWW_WAF_LOG_REQUEST_HEADERS` (default
`accept,accept-language,accept-encoding,content-type,origin,range`): the
request headers recorded in the log line, in either case. `Host` and
`Transfer-Encoding` are refused; the host is the field `host`.
- `SWW_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
- `SWW_WAF_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`,
each with a host and a port and nothing more.
- `SWW_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates that
a `syslog+tls` server's certificate must chain to, instead of the host's own.
A file that cannot be read, or holds no certificate, is refused.
- `SWW_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they wait
to be sent, a whole number above zero.
- `SWW_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
- `SWW_WAF_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates
that a `syslog+tls` server's certificate must chain to, instead of the host's
own. A file that cannot be read, or holds no certificate, is refused.
- `SWW_WAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they
wait to be sent, a whole number above zero.
- `SWW_WAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
`authpriv`, `ftp`, or `local0` to `local7`.
- `SWW_LOG_REMOTE_APP_NAME` (default: `SWW_INSTANCE_NAME`): the app name in each
syslog record, 1 to 48 printable ASCII characters without a space.
- `SWW_WAF_LOG_REMOTE_APP_NAME` (default: `SWW_WAF_INSTANCE_NAME`): the app name
in each syslog record, 1 to 48 printable ASCII characters without a space.
## Metrics
- `SWW_METRICS_TOP_N` (default `50`): how many AS numbers and how many countries
get series of their own. A whole number above zero.
- `SWW_WAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many
countries get series of their own. A whole number above zero.
## Static lists
- `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring
(see step 4 in [`SPEC.md`](../SPEC.md)).
- `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
- `SWW_WAF_ALLOW_NETS` (default empty): netblocks you trust, such as your
monitoring (see step 4 in [`SPEC.md`](../SPEC.md)).
- `SWW_WAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
exempt from rate limiting and the byte limits.
- `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step
4 in [`SPEC.md`](../SPEC.md)).
- `SWW_WAF_DENY_NETS` (default empty): netblocks whose clients are refused (see
step 4 in [`SPEC.md`](../SPEC.md)).
## Request rate limits, per client
The request past one of these limits is refused, and the client is banned.
- `SWW_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
client may make in a minute. A count or `off`. The default leaves room for a
- `SWW_WAF_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests
a client may make in a minute. A count or `off`. The default leaves room for a
person opening two pages at once with an empty cache, up to 60 files each.
- `SWW_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
- `SWW_WAF_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
make in an hour. A count or `off`. It is the main limit against scrapers. A
person reading a site for an hour, a page every 20 seconds, makes about 900. A
scraper making one request every 2 seconds makes 1,800 an hour, more than the
limit.
- `SWW_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may make
in a day. A count or `off`. The default is four hours at the hour limit. A
scraper making one request every 10 seconds makes 8,640 a day, more than the
- `SWW_WAF_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may
make in a day. A count or `off`. The default is four hours at the hour limit.
A scraper making one request every 10 seconds makes 8,640 a day, more than the
limit.
- A CI runner asks its code forge for work all day and can pass the limits. Put
the runners' addresses in `SWW_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
the runners' addresses in `SWW_WAF_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
network where many people share one address, such as an office.
- Counting cannot catch a client that stays under all three limits (see "Request
rate limits, per client" in [`SPEC.md`](../SPEC.md)).
- `SWW_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from rate
limiting and the byte limits, such as `/assets/`.
- `SWW_WAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from
rate limiting and the byte limits, such as `/assets/`.
## Byte limits, per client
@@ -127,236 +131,240 @@ counted for a request and its response are added to the client's total once the
response has ended. The request that takes a client's total past a limit is not
cut off; the client is banned.
- `SWW_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWW_BYTES_LIMIT_PER_HOUR`
(default `50G`) and `SWW_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes
counted for a client in each window, a size or `off`. `SWW_BYTES_COUNT` says
which bytes count: response bodies, request bodies or both. The request that
passes the limit is not cut off, and the client is banned when its response
ends.
- `SWW_BYTES_COUNT` (default `both`): what the byte limits and the byte anomaly
thresholds count: `response`, the app's response bodies; `request`, the
visitors' request bodies; or `both`. What the visitor sent over a WebSocket
counts as request bytes, and what the app sent as response bytes.
- `SWW_WAF_BYTES_LIMIT_PER_MINUTE` (default `off`),
`SWW_WAF_BYTES_LIMIT_PER_HOUR` (default `50G`) and
`SWW_WAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes counted for a
client in each window, a size or `off`. `SWW_WAF_BYTES_COUNT` says which bytes
count: response bodies, request bodies or both. The request that passes the
limit is not cut off, and the client is banned when its response ends.
- `SWW_WAF_BYTES_COUNT` (default `both`): what the byte limits and the byte
anomaly thresholds count: `response`, the app's response bodies; `request`,
the visitors' request bodies; or `both`. What the visitor sent over a
WebSocket counts as request bytes, and what the app sent as response bytes.
## Lookup of AS number and country
- `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
the start while a setting that needs a lookup is set:
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`;
- `SWW_ADD_LOOKUP_HEADERS`;
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming a `country=` tag, or an
`asn=` tag while `SWW_ROUTEWATCH_URL` is unset;
- the two byte percentage lists and `SWW_ASN_LIMIT_PERCENT_URL`;
- `SWW_UNKNOWN_LIMIT_PERCENT` below `100`;
- `SWW_WAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off`
stops the start while a setting that needs a lookup is set:
- `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES`;
- `SWW_WAF_ADD_LOOKUP_HEADERS`;
- `SWW_WAF_TAG_DENY` or `SWW_WAF_TAG_LIMIT_PERCENT` naming a `country=` tag,
or an `asn=` tag while `SWW_WAF_ROUTEWATCH_URL` is unset;
- the two byte percentage lists and `SWW_WAF_ASN_LIMIT_PERCENT_URL`;
- `SWW_WAF_UNKNOWN_LIMIT_PERCENT` below `100`;
- an anomaly threshold per AS number.
- `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
`.mmdb` form, for `SWW_LOOKUP_SOURCE=file`.
- `SWW_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
- `SWW_WAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in
its `.mmdb` form, for `SWW_WAF_LOOKUP_SOURCE=file`.
- `SWW_WAF_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
client's first answer from GeoJS, and how long a request to GeoJS may take. A
duration; never `off`.
- `SWW_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app
the client's AS number in `X-Client-ASN` and its country in
- `SWW_WAF_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the
app the client's AS number in `X-Client-ASN` and its country in
`X-Client-Country`.
## Country list
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
- `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
countries whose clients get through, such as `us,de`. A country whose
`country=` tag is in `SWW_TAG_DENY` stops the start.
`country=` tag is in `SWW_WAF_TAG_DENY` stops the start.
## Request tags
Every request gets tags from what `waf` knows of its client. See "Request tags"
in [`SPEC.md`](../SPEC.md).
- `SWW_TAG_LISTS` (default empty): address lists that each add a tag, as
- `SWW_WAF_TAG_LISTS` (default empty): address lists that each add a tag, as
`name=URL` pairs, such as `torexit=https://example.com/tor.txt`. A name is the
tag, and may hold `=`, such as `vpn=mullvad`. Each list is an `http` or
`https` URL fetched like a blocklist. A name or URL listed twice is refused.
So is a URL that is also in `SWW_BLOCKLIST_URLS`, `SWW_ASN_LIMIT_PERCENT_URL`,
or the CrowdSec decision list.
- `SWW_TAG_DENY` (default empty): tags whose requests are refused, such as
So is a URL that is also in `SWW_WAF_BLOCKLIST_URLS`,
`SWW_WAF_ASN_LIMIT_PERCENT_URL`, or the CrowdSec decision list.
- `SWW_WAF_TAG_DENY` (default empty): tags whose requests are refused, such as
`torexit,country=CN`. A tag listed twice, an `asn=` tag without a number, or a
`country=` tag without a country, is refused.
- `SWW_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's limits, as
tag and percentage pairs, such as `vpn:50,asn=14061:25`. A percentage is from
`1` to `99`, of every rate limit and byte limit. The lowest percentage among a
client's tags applies. A tag listed twice is refused. So is
- `SWW_WAF_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's
limits, as tag and percentage pairs, such as `vpn:50,asn=14061:25`. A
percentage is from `1` to `99`, of every rate limit and byte limit. The lowest
percentage among a client's tags applies. A tag listed twice is refused. So is
`exploitsignature`: a request gets that tag after the rate limits have counted
it.
- `SWW_ROUTEWATCH_URL` (default unset): the `http` or `https` URL of a
- `SWW_WAF_ROUTEWATCH_URL` (default unset): the `http` or `https` URL of a
routewatch instance. [routewatch](https://git.eeqj.de/sneak/routewatch) is a
service that gives an address's announced netblock and origin AS. `waf` asks
it about each client, in the background, and adds the tags `asn=<number>` and
`prefix=<netblock>` from its answer. While it is unset, nothing asks
routewatch and no request gets a `prefix=` tag.
`SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT` accept tags in either case. A tag
that no request can get stops the start. A request can get only these:
`SWW_WAF_TAG_DENY` and `SWW_WAF_TAG_LIMIT_PERCENT` accept tags in either case. A
tag that no request can get stops the start. A request can get only these:
- a name in `SWW_TAG_LISTS`;
- a name in `SWW_WAF_TAG_LISTS`;
- an `asn=` or `country=` tag;
- a `prefix=` tag with a netblock, while `SWW_ROUTEWATCH_URL` is set. It matches
only that exact announced netblock, such as `prefix=192.0.2.0/24`;
- a `dnsbl=` tag of a zone in `SWW_DNSBL_ZONES`, in lower case, with a key
- a `prefix=` tag with a netblock, while `SWW_WAF_ROUTEWATCH_URL` is set. It
matches only that exact announced netblock, such as `prefix=192.0.2.0/24`;
- a `dnsbl=` tag of a zone in `SWW_WAF_DNSBL_ZONES`, in lower case, with a key
masked as the `reputation` field shows it;
- `abuseipdb`, while `SWW_ABUSEIPDB_KEY` is set;
- `exploitsignature`, in `SWW_TAG_DENY` only, while `SWW_RULES_ENABLED` is
`true`, or while neither `SWW_WAF_MODE` nor `SWW_WAF_ANOMALY_THRESHOLD` is
`off`.
- `abuseipdb`, while `SWW_WAF_ABUSEIPDB_KEY` is set;
- `exploitsignature`, in `SWW_WAF_TAG_DENY` only, while `SWW_WAF_RULES_ENABLED`
is `true`, or while neither `SWW_WAF_CORE_RULE_SET_MODE` nor
`SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` is `off`.
`crowdsec` stops the start in both settings, because a CrowdSec decision refuses
the request first.
## Biased thresholds
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): a
percentage of the byte limits alone, for the AS numbers or countries listed,
such as `AS14061:50` or `cn:25`. It takes the place of the percentage
`SWW_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number or
country listed twice is refused.
- `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
lookup cannot place.
- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
text file with one AS number and percentage on each line, such as
`AS64496:50`. Each entry counts like an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`.
A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
- `SWW_WAF_ASN_BYTES_PERCENT` and `SWW_WAF_COUNTRY_BYTES_PERCENT` (default
empty): a percentage of the byte limits alone, for the AS numbers or countries
listed, such as `AS14061:50` or `cn:25`. It takes the place of the percentage
`SWW_WAF_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number
or country listed twice is refused.
- `SWW_WAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client
the lookup cannot place.
- `SWW_WAF_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of
a text file with one AS number and percentage on each line, such as
`AS64496:50`. Each entry counts like an `asn=` tag in
`SWW_WAF_TAG_LIMIT_PERCENT`. A URL that is also in `SWW_WAF_BLOCKLIST_URLS` is
refused.
## Attack detection
- `SWW_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule files.
- `SWW_RULES_ENABLED` (default `true`): `false` reads no rule file.
- `SWW_TRAP_PATHS` (default empty): paths only scanners ask for, such as
- `SWW_WAF_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule
files.
- `SWW_WAF_RULES_ENABLED` (default `true`): `false` reads no rule file.
- `SWW_WAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
- `SWW_WAF_MODE` (default `block`): `off`, `detect` or `block`.
- `SWW_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level,
from 1 to 4.
- `SWW_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a
match. A whole number above zero, or `off`.
- `SWW_WAF_DISABLED_RULES` (default
- `SWW_WAF_CORE_RULE_SET_MODE` (default `block`): `off`, `detect` or `block`.
- `SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL` (default `1`): the Core Rule Set's
paranoia level, from 1 to 4.
- `SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD` (default `5`): the score at which a
request is a match. A whole number above zero, or `off`.
- `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` (default
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
to switch off. Each a whole number above zero. An id from 900000 to 900999 is
refused: those are the rules that set the Core Rule Set up, and
`smallwebwaf`'s own.
- `SWW_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does
not inspect.
- `SWW_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body the
Core Rule Set reads, a size of at most `64K`. A question carries no more of a
body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one byte of
it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule Set
exclusions, written as Coraza directives. It is loaded before the Core Rule
Set's rules, so it holds only `SecAction` and `SecRule` lines that exclude
with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that cannot be
read or does not parse stops the start, whatever `SWW_WAF_MODE` is. So does a
file that names a number from 900000 to 900999 outside a comment, alone or in
a range. So does a directive that removes or changes rules as the file loads,
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
yet. It has no `_FILE` form.
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may get
in a minute from a rule file, a trap path or the Core Rule Set, a count or
- `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` (default empty): path prefixes the Core
Rule Set does not inspect.
- `SWW_WAF_CORE_RULE_SET_BODY_LIMIT` (default `off`): `off`, or how much of a
request body the Core Rule Set reads, a size of at most `64K`. A question
carries no more of a body than 64 KiB, and the Core Rule Set reads at most 64
KiB less one byte of it, whatever the value (see "The answer" in
[`SPEC.md`](../SPEC.md)).
- `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule
Set exclusions, written as Coraza directives. It is loaded before the Core
Rule Set's rules, so it holds only `SecAction` and `SecRule` lines that
exclude with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that
cannot be read or does not parse stops the start, whatever
`SWW_WAF_CORE_RULE_SET_MODE` is. So does a file that names a number from
900000 to 900999 outside a comment, alone or in a range. So does a directive
that removes or changes rules as the file loads, such as `SecRuleRemoveById`,
since none of the Core Rule Set's rules is loaded yet. It has no `_FILE` form.
- `SWW_WAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may
get in a minute from a rule file, a trap path or the Core Rule Set, a count or
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
## Ban lengths, response and scope
- `SWW_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
- `SWW_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A
duration.
- `SWW_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
- `SWW_WAF_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
- `SWW_WAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit.
A duration.
- `SWW_WAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
within which breaking a limit again lengthens the next ban. A duration.
- `SWW_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit; one
that would be longer is permanent. A duration.
- `SWW_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
attack. A duration.
- `SWW_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
- `SWW_WAF_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit;
one that would be longer is permanent. A duration.
- `SWW_WAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign
of attack. A duration.
- `SWW_WAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
covers: the prefix length of its netblock, from 0 to 32.
## Reputation
- `SWW_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
- `SWW_WAF_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
`https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL
without a user or a fragment. A URL listed twice is refused.
- `SWW_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its last
try before it is fetched again. A duration of `1h` or more.
- `SWW_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`.
- `SWW_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as
- `SWW_WAF_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its
last try before it is fetched again. A duration of `1h` or more.
- `SWW_WAF_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or
`log`.
- `SWW_WAF_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as
`dnsbl.dronebl.org`. Each a DNS name of at most 189 characters. A Spamhaus
zone carries its key, as in `<key>.xbl.dq.spamhaus.net`. A zone listed twice
is refused, whatever its case or key.
- `SWW_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through,
such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an optional
port, 53 when none is given. Unset, the host's resolver is used.
- `SWW_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
- `SWW_WAF_DNSBL_RESOLVER` (default unset): the resolver to ask the zones
through, such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an
optional port, 53 when none is given. Unset, the host's resolver is used.
- `SWW_WAF_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
control character in it is refused.
- `SWW_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score,
from 0 to 100, that is a hit.
- `SWW_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day, a
whole number above zero. AbuseIPDB's free accounts may make 1,000.
- `SWW_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine
you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL without a
user or a fragment; its decision list is at `v1/decisions` under it. Set
without the key, it is refused. So is a decision list URL that is also in
`SWW_BLOCKLIST_URLS`, or that is `SWW_ASN_LIMIT_PERCENT_URL`.
- `SWW_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
- `SWW_WAF_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence
score, from 0 to 100, that is a hit.
- `SWW_WAF_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a
day, a whole number above zero. AbuseIPDB's free accounts may make 1,000.
- `SWW_WAF_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec
engine you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL
without a user or a fragment; its decision list is at `v1/decisions` under it.
Set without the key, it is refused. So is a decision list URL that is also in
`SWW_WAF_BLOCKLIST_URLS`, or that is `SWW_WAF_ASN_LIMIT_PERCENT_URL`.
- `SWW_WAF_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
URL, it is refused.
- `SWW_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
- `SWW_WAF_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
`log`, for DNS blocklist and AbuseIPDB hits.
- `SWW_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
- `SWW_WAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
AbuseIPDB's score is used. A duration.
- `SWW_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a check
with AbuseIPDB may take. A duration.
- `SWW_WAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a
check with AbuseIPDB may take. A duration.
## Alerting
- `SWW_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each alert
as a JSON object. An `http` or `https` URL without a user or a fragment.
- `SWW_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post, such
as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and value. A
value cannot hold a comma.
- `SWW_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook that
receives each alert as a message, checked as the webhook URL is.
- `SWW_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each alert,
as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked as the
webhook URL is.
- `SWW_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
- `SWW_WAF_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each
alert as a JSON object. An `http` or `https` URL without a user or a fragment.
- `SWW_WAF_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post,
such as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and
value. A value cannot hold a comma.
- `SWW_WAF_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook
that receives each alert as a message, checked as the webhook URL is.
- `SWW_WAF_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each
alert, as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked
as the webhook URL is.
- `SWW_WAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
character in it is refused.
- `SWW_ALERT_EVENTS` (default
- `SWW_WAF_ALERT_EVENTS` (default
`ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error`):
the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
but `summary`. Any other name is refused.
- `SWW_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
- `SWW_WAF_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
repeats are held back. A duration or `off`.
- `SWW_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A
whole number above zero, or `off`.
- `SWW_WAF_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour.
A whole number above zero, or `off`.
## Anomaly thresholds
Each scope has four thresholds: the most requests and the most body bytes
counted in a minute and in an hour. The bytes are those `SWW_BYTES_COUNT` names.
Requests are counts, bytes are sizes. Each is `off` by default. The four
counted in a minute and in an hour. The bytes are those `SWW_WAF_BYTES_COUNT`
names. Requests are counts, bytes are sizes. Each is `off` by default. The four
thresholds per client are:
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
- `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
- `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR`.
- `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
- `SWW_WAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
- `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
- `SWW_WAF_ANOMALY_CLIENT_BYTES_PER_HOUR`.
The other scopes' names have another prefix before the same four endings, such
as `REQUESTS_PER_MINUTE`:
- `SWW_ANOMALY_NET_...`: per netblock around a client. Two settings give the
- `SWW_WAF_ANOMALY_NET_...`: per netblock around a client. Two settings give the
netblock's length:
- `SWW_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
- `SWW_WAF_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
client;
- `SWW_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
- `SWW_WAF_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
client.
- `SWW_ANOMALY_ASN_...`: per AS number.
- `SWW_ANOMALY_TOTAL_...`: the whole service.
- `SWW_WATCH_...`: each netblock in `SWW_WATCH_NETS`, as a whole.
`SWW_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such as
`office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
- `SWW_WAF_ANOMALY_ASN_...`: per AS number.
- `SWW_WAF_ANOMALY_TOTAL_...`: the whole service.
- `SWW_WAF_WATCH_...`: each netblock in `SWW_WAF_WATCH_NETS`, as a whole.
`SWW_WAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such
as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
name, or with a bad netblock, is refused. So is a name used twice.
## Settings given as files
@@ -377,7 +385,7 @@ Then mount its directory into the container read-only, and name the file:
docker run \
-v /srv/app/tokens:/etc/sww/tokens:ro \
-e SWW_COMMAND=waf \
-e SWW_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \
-e SWW_WAF_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \
...
```
@@ -400,51 +408,53 @@ A few limits are fixed rather than settings:
## `waf`'s answer and `rproxy`'s wait
`SWW_LOOKUP_TIMEOUT` is the longest a question waits on an outside source. Keep
it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question waits
for `SWW_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are asked
in the background (see "Answering in time" under "`waf`" in
`SWW_WAF_LOOKUP_TIMEOUT` is the longest a question waits on an outside source.
Keep it well under `rproxy`'s `SWW_RPROXY_TIMEOUT_MS`, as the default is. No
question waits for `SWW_WAF_REPUTATION_TIMEOUT`, since the DNS blocklists and
AbuseIPDB are asked in the background (see "Answering in time" under "`waf`" in
[`SPEC.md`](../SPEC.md)).
## `rproxy`
`sww rproxy` reads the settings below and no others. They are read once at
start, and an invalid value stops the start. None can be given as a file, since
`rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what
they do.
start, and an invalid value stops the start. Any can be given as a file, as a
`SWW_WAF_` setting can. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what they do.
A time here is a whole number of milliseconds above zero, such as `2000`.
- `SWRP_WAF_BACKEND` (required, no default): `waf`'s URL. An `http` or `https`
URL with a host and an optional port, and nothing more. `rproxy` speaks
HTTP/1.1 to it.
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `waf`'s
- `SWW_RPROXY_WAF_BACKEND` (required, no default): `waf`'s URL. An `http` or
`https` URL with a host and an optional port, and nothing more. `rproxy`
speaks HTTP/1.1 to it.
- `SWW_RPROXY_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `waf`'s
answer, to a question and to a report. A time; never `off`.
- `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request when
`waf` cannot be connected to, does not answer at all, or does not answer
within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny` answers
`503`. It does not apply to an answer `waf` gives, such as an error.
- `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or
`off`.
- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or
`off`.
- `SWRP_MAX_REQUEST_TIME_MS` (default `60000`): how long the visitor may take to
send its request line and headers. It then also bounds, from the end of the
headers, the time until the app has been sent the whole request. A time or
`off`.
- `SWRP_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may take,
from the end of the request to its last byte reaching the visitor. An answer
`rproxy` gives itself always has at least 10 seconds to be sent, and so do the
status and headers of the app's answer. A time or `off`.
- `SWRP_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as `:9000`.
- `SWRP_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An `http` or
`https` URL with a host and an optional port, and nothing more.
- `SWRP_TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`):
the netblocks whose `X-Forwarded-For` is believed. Set but empty, it trusts
nothing. A client outside these netblocks cannot claim another address. Where
other containers reach `rproxy` directly, or visitors on a private network
reach traefik, they can name another address in the header. Narrowing the
setting to traefik's own network closes that gap.
- `SWW_RPROXY_DEFAULT_DISPOSITION` (default `allow`): what is done with a
request when `waf` cannot be connected to, does not answer at all, or does not
answer within `SWW_RPROXY_TIMEOUT_MS`. `allow` passes it to the app, and
`deny` answers `503`. It does not apply to an answer `waf` gives, such as an
error.
- `SWW_RPROXY_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A
size or `off`.
- `SWW_RPROXY_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A
size or `off`.
- `SWW_RPROXY_MAX_REQUEST_TIME_MS` (default `60000`): how long the visitor may
take to send its request line and headers. It then also bounds, from the end
of the headers, the time until the app has been sent the whole request. A time
or `off`.
- `SWW_RPROXY_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may
take, from the end of the request to its last byte reaching the visitor. An
answer `rproxy` gives itself always has at least 10 seconds to be sent, and so
do the status and headers of the app's answer. A time or `off`.
- `SWW_RPROXY_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as
`:9000`.
- `SWW_RPROXY_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An
`http` or `https` URL with a host and an optional port, and nothing more.
- `SWW_RPROXY_TRUSTED_PROXIES` (default
`10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`): the netblocks whose
`X-Forwarded-For` is believed. Set but empty, it trusts nothing. A client
outside these netblocks cannot claim another address. Where other containers
reach `rproxy` directly, or visitors on a private network reach traefik, they
can name another address in the header. Narrowing the setting to traefik's own
network closes that gap.
## The image's command
@@ -456,14 +466,14 @@ It cannot be given as a file.
Nothing reads these names any more:
- `SWW_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`.
- `SWW_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`.
- `SWW_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`.
- `SWW_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`.
- `SWW_CLIENT_REQUEST_TIMEOUT` and `SWW_UPSTREAM_REQUEST_TIMEOUT` are
`SWRP_MAX_REQUEST_TIME_MS`, in milliseconds.
- `SWW_CLIENT_RESPONSE_TIMEOUT` and `SWW_UPSTREAM_RESPONSE_TIMEOUT` are
`SWRP_MAX_RESPONSE_TIME_MS`, in milliseconds.
- `SWW_CLIENT_IDLE_TIMEOUT` and `SWW_CLIENT_REQUEST_HEADER_MAX_BYTES` have no
setting. `rproxy` closes a kept-open connection idle for 120 seconds, and
takes a request line and headers of at most 32 KiB.
- `SWW_WAF_UPSTREAM_URL` is `SWW_RPROXY_UPSTREAM_URL`.
- `SWW_WAF_TRUSTED_PROXIES` is `SWW_RPROXY_TRUSTED_PROXIES`.
- `SWW_WAF_REQUEST_MAX_BYTES` is `SWW_RPROXY_MAX_REQUEST_SIZE`.
- `SWW_WAF_RESPONSE_MAX_BYTES` is `SWW_RPROXY_MAX_RESPONSE_SIZE`.
- `SWW_WAF_CLIENT_REQUEST_TIMEOUT` and `SWW_WAF_UPSTREAM_REQUEST_TIMEOUT` are
`SWW_RPROXY_MAX_REQUEST_TIME_MS`, in milliseconds.
- `SWW_WAF_CLIENT_RESPONSE_TIMEOUT` and `SWW_WAF_UPSTREAM_RESPONSE_TIMEOUT` are
`SWW_RPROXY_MAX_RESPONSE_TIME_MS`, in milliseconds.
- `SWW_WAF_CLIENT_IDLE_TIMEOUT` and `SWW_WAF_CLIENT_REQUEST_HEADER_MAX_BYTES`
have no setting. `rproxy` closes a kept-open connection idle for 120 seconds,
and takes a request line and headers of at most 32 KiB.
+78 -23
View File
@@ -1,13 +1,28 @@
# State files
# State and cache files
The five JSON files in `SWW_STATE_DIR`, and what each holds. "Persistent state"
in [`SPEC.md`](../SPEC.md) says how they are written, read and edited. Each has
a top-level `version` of 1.
The JSON files `waf` keeps, and what each holds. "Persistent state" in
[`SPEC.md`](../SPEC.md) says how they are written, read and edited. Each has a
top-level `version` of 1.
- The state files are in `SWW_WAF_STATE_DIR`, `/var/lib/sww` by default. They
hold what cannot be fetched again, so back them up:
- `bans.json`;
- `clients.json`;
- `abuseipdb-checks-spent.json`;
- `alerts.json`.
- The cache files are in `SWW_WAF_CACHE_DIR`, `/var/cache/sww` by default. They
hold answers from outside services, which are asked again when a file is lost,
so the directory can be left out of backups:
- `lookups.json`;
- `lists.json`;
- `dnsbl.json`;
- `abuseipdb-scores.json`;
- `routewatch.json`.
## `bans.json`
Active and past bans: every ban an admin made, and up to `SWW_MAX_BANS` of those
`waf` made. Per entry:
Active and past bans: every ban an admin made, and up to `SWW_WAF_MAX_BANS` of
those `waf` made. Per entry:
- `netblock`, `start` and `expires` (`null` for a permanent ban);
- `cause`: `attack`, `limit`, `admin` or `crowdsec`;
@@ -67,21 +82,13 @@ since it was first seen:
`blocked_by_core_rule_set`. A file may also hold `token_refused`, which is
kept, and which nothing adds to.
## `lookups.json`
## `abuseipdb-checks-spent.json`
GeoJS answers, one per line:
The AbuseIPDB checks spent today, so that a restart does not spend the daily
budget again:
- `client`;
- `asn`, `as_name` and `country`;
- `answered`: when GeoJS answered;
- `used`: when the answer was last used.
## `reputation.json`
- the last good copy of each list fetched from a URL, with when it was last
tried;
- each DNS blocklist verdict still in use;
- AbuseIPDB's scores, and the checks spent today.
- `day`: the day, in UTC;
- `spent`: the checks spent on it.
## `alerts.json`
@@ -91,15 +98,63 @@ GeoJS answers, one per line:
- the alerts sent and held back in the current hour;
- the alerts waiting for each destination.
## `lookups.json`
A cache file. GeoJS answers, one per line:
- `client`;
- `asn`, `as_name` and `country`;
- `answered`: when GeoJS answered;
- `used`: when the answer was last used.
## `lists.json`
A cache file. The last good copy of each list fetched from a URL, with when it
was last tried:
- `url`;
- `tried`: when it was last fetched or tried;
- `fetched` and `lines`: when the last good copy was fetched, and its lines,
comment lines included.
## `dnsbl.json`
A cache file. Each DNS blocklist verdict still in use, one per line:
- `zone` and `client`;
- `listed`: whether the zone lists the client;
- `fetched`: when the zone answered.
## `abuseipdb-scores.json`
A cache file. AbuseIPDB's scores still in use, one per line:
- `client`;
- `score`: from 0 to 100;
- `fetched`: when AbuseIPDB answered.
## `routewatch.json`
A cache file. routewatch's answers from the last hour, one per line:
- `client`;
- `netblock`: the netblock the client's address is announced in, empty for an
address without a live route;
- `asn`: the number of the AS that originates the netblock, 0 for none;
- `answered`: when routewatch answered.
## Sizes
- `clients.json`: about 1 KiB per client, about 20 MiB at the default
`SWW_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of
disk writes a day.
`SWW_WAF_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB
of disk writes a day.
- `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at
the default `SWW_MAX_BANS`. Written every 15 minutes, a full file comes to
the default `SWW_WAF_MAX_BANS`. Written every 15 minutes, a full file comes to
about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB
more.
- `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when
full.
- `reputation.json` and `alerts.json`: usually a few MiB or less.
- `routewatch.json`: about 120 bytes per answer, at most about 12 MiB.
- `abuseipdb-checks-spent.json`: a few bytes.
- `lists.json`, `dnsbl.json`, `abuseipdb-scores.json` and `alerts.json`: usually
a few MiB or less.
+10
View File
@@ -11,12 +11,14 @@ require (
github.com/maxmind/mmdbwriter v1.2.0
github.com/oschwald/maxminddb-golang/v2 v2.7.0
github.com/prometheus/client_golang v1.24.1
github.com/spf13/viper v1.21.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/corazawaf/libinjection-go v0.3.4 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect
@@ -26,14 +28,22 @@ require (
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/magefile/mage v1.17.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/net v0.60.0 // indirect
golang.org/x/sync v0.23.0 // indirect
+24
View File
@@ -16,8 +16,12 @@ github.com/corazawaf/libinjection-go v0.3.4 h1:jX2Tit2xZxYJJyVryNXucYBZtQqS7473x
github.com/corazawaf/libinjection-go v0.3.4/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk=
github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0=
github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
@@ -38,6 +42,10 @@ github.com/kaptinlin/jsonschema v0.4.6 h1:vOSFg5tjmfkOdKg+D6Oo4fVOM/pActWu/ntkPs
github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM=
@@ -62,8 +70,24 @@ github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+35 -33
View File
@@ -1,12 +1,12 @@
// Package alerts sends alerts on bans, on traffic over an anomaly
// threshold, on a source that fails and on a file with an error to each
// destination set: to the webhook
// SWW_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
// SWW_WAF_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
// object" in docs/alerts.md describes, to the Slack incoming
// webhook SWW_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
// ntfy topic SWW_ALERT_NTFY_URL names. A repeat within
// SWW_ALERT_COOLDOWN is held back, and so is an alert past
// SWW_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
// webhook SWW_WAF_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
// ntfy topic SWW_WAF_ALERT_NTFY_URL names. A repeat within
// SWW_WAF_ALERT_COOLDOWN is held back, and so is an alert past
// SWW_WAF_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
// bounded queue of each destination's own, so that a destination that is
// slow or unreachable holds up neither the others nor any request. The
// state is written to alerts.json and read from it by the state package.
@@ -34,7 +34,7 @@ import (
"time"
)
// The events an alert is for, as SWW_ALERT_EVENTS names them.
// The events an alert is for, as SWW_WAF_ALERT_EVENTS names them.
const (
// EventBan is a ban sww made.
EventBan = "ban"
@@ -45,7 +45,7 @@ const (
// threshold.
EventAnomaly = "anomaly"
// EventCoreRuleSetMatch is a request the Core Rule Set scored at or over
// SWW_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD, refused in block mode, let through in
// detect mode.
EventCoreRuleSetMatch = "core_rule_set_match"
// EventReputationHit is a request whose client a blocklist, the
@@ -56,19 +56,21 @@ const (
// of a list failing, a query to a DNSBL zone or a check with AbuseIPDB
// failing or refused, or the day's AbuseIPDB checks used up.
EventSourceFailure = "source_failure"
// EventFileError is a rule file or state file edited while sww
// runs that does not parse, a replacement of the lookup database that
// cannot be read, or a state file that cannot be written.
// EventFileError is one of:
// - a rule file or state file edited while sww runs that does not parse;
// - a cache file that does not parse, at start or after an edit;
// - a state file or cache file that cannot be written;
// - a replacement of the lookup database that cannot be read.
EventFileError = "file_error"
// EventSummary is the summary sent as an hour ends: of the alerts held
// back in it past SWW_ALERT_MAX_PER_HOUR, and of the repeats held
// back in it past SWW_WAF_ALERT_MAX_PER_HOUR, and of the repeats held
// back by the cooldowns dropped as it ends, which no alert let through
// has given. It is sent with SWW_ALERT_MAX_PER_HOUR off too, for
// those repeats. SWW_ALERT_EVENTS does not name it.
// has given. It is sent with SWW_WAF_ALERT_MAX_PER_HOUR off too, for
// those repeats. SWW_WAF_ALERT_EVENTS does not name it.
EventSummary = "summary"
)
// Events returns every event SWW_ALERT_EVENTS can name, which is its
// Events returns every event SWW_WAF_ALERT_EVENTS can name, which is its
// default.
func Events() []string {
return []string{
@@ -80,12 +82,12 @@ func Events() []string {
// The destinations alerts are sent to, as the metrics and alerts.json
// name them.
const (
// DestinationWebhook is the webhook SWW_ALERT_WEBHOOK_URL names.
// DestinationWebhook is the webhook SWW_WAF_ALERT_WEBHOOK_URL names.
DestinationWebhook = "webhook"
// DestinationSlack is the Slack incoming webhook
// SWW_ALERT_SLACK_WEBHOOK_URL names.
// SWW_WAF_ALERT_SLACK_WEBHOOK_URL names.
DestinationSlack = "slack"
// DestinationNtfy is the ntfy topic SWW_ALERT_NTFY_URL names.
// DestinationNtfy is the ntfy topic SWW_WAF_ALERT_NTFY_URL names.
DestinationNtfy = "ntfy"
)
@@ -121,26 +123,26 @@ var (
// NtfyURL set, no alert is sent.
type Params struct {
// WebhookURL is where each alert is posted as JSON
// (SWW_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
// are sent with each (SWW_ALERT_WEBHOOK_HEADERS).
// (SWW_WAF_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
// are sent with each (SWW_WAF_ALERT_WEBHOOK_HEADERS).
WebhookURL *url.URL
WebhookHeaders http.Header
// SlackURL is the Slack incoming webhook each alert is posted to as a
// message (SWW_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
// message (SWW_WAF_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
SlackURL *url.URL
// NtfyURL is the ntfy topic each alert is published to
// (SWW_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
// empty, is sent with each as a bearer token (SWW_ALERT_NTFY_TOKEN).
// (SWW_WAF_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
// empty, is sent with each as a bearer token (SWW_WAF_ALERT_NTFY_TOKEN).
NtfyURL *url.URL
NtfyToken string
// Events are the events alerts are sent for (SWW_ALERT_EVENTS).
// Events are the events alerts are sent for (SWW_WAF_ALERT_EVENTS).
Events []string
// Cooldown is how long a repeat of an alert is held back
// (SWW_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
// sent in an hour (SWW_ALERT_MAX_PER_HOUR), 0 for no limit.
// (SWW_WAF_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
// sent in an hour (SWW_WAF_ALERT_MAX_PER_HOUR), 0 for no limit.
Cooldown time.Duration
MaxPerHour int
// Instance is SWW_INSTANCE_NAME, which every alert gives.
// Instance is SWW_WAF_INSTANCE_NAME, which every alert gives.
Instance string
// Now tells the time of an alert, normally time.Now in UTC.
Now func() time.Time
@@ -301,17 +303,17 @@ func New(params Params) *Queue {
}
if params.WebhookURL != nil {
q.addDestination(DestinationWebhook, "SWW_ALERT_WEBHOOK_URL",
q.addDestination(DestinationWebhook, "SWW_WAF_ALERT_WEBHOOK_URL",
params.WebhookURL, q.webhookMessage)
}
if params.SlackURL != nil {
q.addDestination(DestinationSlack, "SWW_ALERT_SLACK_WEBHOOK_URL",
q.addDestination(DestinationSlack, "SWW_WAF_ALERT_SLACK_WEBHOOK_URL",
params.SlackURL, slackMessage)
}
if params.NtfyURL != nil {
q.addDestination(DestinationNtfy, "SWW_ALERT_NTFY_URL",
q.addDestination(DestinationNtfy, "SWW_WAF_ALERT_NTFY_URL",
params.NtfyURL, q.ntfyMessage)
}
@@ -319,7 +321,7 @@ func New(params Params) *Queue {
}
// Raise sends alert, which names its event and what is particular to it,
// unless no destination is set or SWW_ALERT_EVENTS leaves its event
// unless no destination is set or SWW_WAF_ALERT_EVENTS leaves its event
// out. It gives alert the instance and the time. An alert that repeats
// the last one let through less than Cooldown before is held back and
// counted. The next one let through gives that count, unless an hour of
@@ -363,7 +365,7 @@ func (q *Queue) Raise(alert Alert) {
}
// WouldSend reports whether Raise would let an alert for event on
// netblock through now: a destination is set, SWW_ALERT_EVENTS chooses
// netblock through now: a destination is set, SWW_WAF_ALERT_EVENTS chooses
// event, no alert for event on netblock was let through less than
// Cooldown before, and fewer than MaxPerHour alerts have been let through
// in the hour under way. Unlike Raise, it counts nothing.
@@ -608,13 +610,13 @@ func (q *Queue) endHour(now time.Time) {
if heldBack > 0 {
reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+
"past the %d an hour SWW_ALERT_MAX_PER_HOUR allows", heldBack,
"past the %d an hour SWW_WAF_ALERT_MAX_PER_HOUR allows", heldBack,
q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour))
}
if repeats > 0 {
reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+
"SWW_ALERT_COOLDOWN that no later alert gives", repeats))
"SWW_WAF_ALERT_COOLDOWN that no later alert gives", repeats))
}
if len(reasons) > 0 {
+11 -11
View File
@@ -132,7 +132,7 @@ func TestWouldSendOnlyForTheChosenEvents(t *testing.T) {
if q.WouldSend(alerts.EventBan, netblock(1)) ||
q.WouldSend(alerts.EventPermanentBan, netblock(1)) {
t.Error("a ban alert would be sent, though SWW_ALERT_EVENTS leaves it out")
t.Error("a ban alert would be sent, though SWW_WAF_ALERT_EVENTS leaves it out")
}
if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) {
@@ -184,8 +184,8 @@ func TestWouldSendWithOnlySlackOrOnlyNtfySet(t *testing.T) {
onlyNtfy.NtfyURL = parseURL(ntfyURL)
for setting, params := range map[string]alerts.Params{
"SWW_ALERT_SLACK_WEBHOOK_URL": onlySlack,
"SWW_ALERT_NTFY_URL": onlyNtfy,
"SWW_WAF_ALERT_SLACK_WEBHOOK_URL": onlySlack,
"SWW_WAF_ALERT_NTFY_URL": onlyNtfy,
} {
if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) {
t.Errorf("a ban alert would not be sent with only %s set", setting)
@@ -359,7 +359,7 @@ func TestAlertsPastTheHourlyLimitAreRolledIntoOneSummary(t *testing.T) {
"as_name": "",
"country": "",
"reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
"past the 2 an hour SWW_ALERT_MAX_PER_HOUR allows",
"past the 2 an hour SWW_WAF_ALERT_MAX_PER_HOUR allows",
"detail": map[string]any{
"hour": started,
"count": float64(3),
@@ -525,7 +525,7 @@ func TestFailedRequestIsSentAgainWithBackoff(t *testing.T) {
wantCounts(t, q, 0, int64(len(want)), 0, 0)
if !strings.Contains(log.String(),
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) {
`"msg":"sending an alert to SWW_WAF_ALERT_WEBHOOK_URL failed"`) {
t.Errorf("process log %q names no failure", log.String())
}
@@ -595,7 +595,7 @@ func TestRefusedAlertIsGivenUpAndTheNextSent(t *testing.T) {
wantCounts(t, q, 1, 3, 0, 1)
if !strings.Contains(log.String(),
`"msg":"gave up an alert SWW_ALERT_WEBHOOK_URL refused"`) {
`"msg":"gave up an alert SWW_WAF_ALERT_WEBHOOK_URL refused"`) {
t.Errorf("process log %q names no alert given up", log.String())
}
})
@@ -619,7 +619,7 @@ func TestFailedRequestIsLoggedWithoutTheURL(t *testing.T) {
logged := log.String()
if !strings.Contains(logged,
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) ||
`"msg":"sending an alert to SWW_WAF_ALERT_WEBHOOK_URL failed"`) ||
strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") {
t.Errorf("process log %q names no failure, or names the URL", logged)
}
@@ -782,8 +782,8 @@ func TestSlackAndNtfyAreSentTheSummaryAndTheRepeatsHeldBack(t *testing.T) {
}
const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
"past the 1 an hour SWW_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
"by SWW_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
"past the 1 an hour SWW_WAF_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
"by SWW_WAF_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary)
wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary)
@@ -901,7 +901,7 @@ func TestDestinationThatDoesNotAnswerHoldsUpNeitherOther(t *testing.T) {
logged := log.String()
if !strings.Contains(logged,
`"msg":"sending an alert to SWW_ALERT_SLACK_WEBHOOK_URL failed"`) ||
`"msg":"sending an alert to SWW_WAF_ALERT_SLACK_WEBHOOK_URL failed"`) ||
strings.Contains(logged, "hooks.slack.example") ||
strings.Contains(logged, "T0123") {
t.Errorf("process log %q names no failure, or names the URL", logged)
@@ -1254,7 +1254,7 @@ type eventMessage struct {
title, priorityAndTag, text string
}
// anAlertForEachEvent returns an alert for each event SWW_ALERT_EVENTS
// anAlertForEachEvent returns an alert for each event SWW_WAF_ALERT_EVENTS
// names, the first in observe mode, each with its message.
func anAlertForEachEvent() []eventMessage {
return []eventMessage{
+12 -12
View File
@@ -28,16 +28,16 @@ const maxCounters = 20000
// alerts name them.
const (
// ScopeClient is one client: an IPv4 address, or an IPv6 netblock of
// SWW_IPV6_GROUP_PREFIX.
// SWW_WAF_IPV6_GROUP_PREFIX.
ScopeClient = "client"
// ScopeNet is the netblock around a client, SWW_ANOMALY_NET_V4_PREFIX
// or SWW_ANOMALY_NET_V6_PREFIX long.
// ScopeNet is the netblock around a client, SWW_WAF_ANOMALY_NET_V4_PREFIX
// or SWW_WAF_ANOMALY_NET_V6_PREFIX long.
ScopeNet = "net"
// ScopeASN is an AS number.
ScopeASN = "asn"
// ScopeTotal is the whole service.
ScopeTotal = "total"
// ScopeWatchedNet is a named netblock of SWW_WATCH_NETS.
// ScopeWatchedNet is a named netblock of SWW_WAF_WATCH_NETS.
ScopeWatchedNet = "watched_net"
)
@@ -62,7 +62,7 @@ type Thresholds struct {
BytesPerHour int64
}
// NamedNetblock is a netblock SWW_WATCH_NETS names.
// NamedNetblock is a netblock SWW_WAF_WATCH_NETS names.
type NamedNetblock struct {
Name string
Netblock netip.Prefix
@@ -70,14 +70,14 @@ type NamedNetblock struct {
// Params are what New needs.
type Params struct {
// The thresholds of each scope: SWW_ANOMALY_CLIENT_*,
// SWW_ANOMALY_NET_*, SWW_ANOMALY_ASN_*, SWW_ANOMALY_TOTAL_* and
// SWW_WATCH_*.
// The thresholds of each scope: SWW_WAF_ANOMALY_CLIENT_*,
// SWW_WAF_ANOMALY_NET_*, SWW_WAF_ANOMALY_ASN_*, SWW_WAF_ANOMALY_TOTAL_* and
// SWW_WAF_WATCH_*.
Client, Net, ASN, Total, Watch Thresholds
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
// client (SWW_ANOMALY_NET_V4_PREFIX and SWW_ANOMALY_NET_V6_PREFIX).
// client (SWW_WAF_ANOMALY_NET_V4_PREFIX and SWW_WAF_ANOMALY_NET_V6_PREFIX).
NetV4Prefix, NetV6Prefix int
// NamedNetblocks are SWW_WATCH_NETS.
// NamedNetblocks are SWW_WAF_WATCH_NETS.
NamedNetblocks []NamedNetblock
// Alerts receive the anomaly alerts.
Alerts *alerts.Queue
@@ -107,13 +107,13 @@ type Request struct {
Host string
// Client is the client's address, and ClientGroup the client it is
// counted as: its IPv4 address, or the IPv6 netblock of
// SWW_IPV6_GROUP_PREFIX its address is in.
// SWW_WAF_IPV6_GROUP_PREFIX its address is in.
Client netip.Addr
ClientGroup netip.Prefix
// ASN, ASName and Country are the client's as looked up, each "" when
// unknown.
ASN, ASName, Country string
// Bytes are the request's bytes, as SWW_BYTES_COUNT counts them.
// Bytes are the request's bytes, as SWW_WAF_BYTES_COUNT counts them.
Bytes int64
}
+2 -2
View File
@@ -123,7 +123,7 @@ type Notes struct {
LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
// RuleID and Target are, for a ban for a clear sign of attack, the id
// of the rule file rule that matched, and its target; TrapPath is, for
// one for a request for a path in SWW_TRAP_PATHS, that path as the
// one for a request for a path in SWW_WAF_TRAP_PATHS, that path as the
// client sent it.
RuleID string `json:"rule_id,omitempty"`
Target string `json:"target,omitempty"`
@@ -199,7 +199,7 @@ type Ledger struct {
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
// netblocks that have been banned. Check looks for a ban at each of
// them, so that a ban read from bans.json refuses every client in its
// netblock even when it was made with another SWW_BAN_SCOPE_V4_PREFIX,
// netblock even when it was made with another SWW_WAF_BAN_SCOPE_V4_PREFIX,
// or another length of an IPv6 client's netblock.
v4Lengths, v6Lengths []int
}
+294 -278
View File
@@ -1,10 +1,9 @@
// Package config reads sww's settings, and is the one place they are
// read. Every setting is an environment variable. The settings of `sww
// waf` start with SWW_, every one of them has a default, and most can be
// given as a file instead. Those of `sww rproxy` start with SWRP_. Each
// of them but SWRP_WAF_BACKEND has a default, and none can be given as a file,
// since rproxy never touches disk. SWW_COMMAND chooses which of the two
// the image runs.
// waf` start with SWW_WAF_, every one of them has a default, and most can be
// given as a file instead. Those of `sww rproxy` start with SWW_RPROXY_. Each
// of them but SWW_RPROXY_WAF_BACKEND has a default, and each can be given as a file
// too. SWW_COMMAND chooses which of the two the image runs.
package config
import (
@@ -27,6 +26,8 @@ import (
"unicode"
"unicode/utf8"
"github.com/spf13/viper"
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/remotelog"
@@ -35,121 +36,121 @@ import (
// Config is the settings of `sww waf`. A time or size limit of
// zero is off.
type Config struct {
// ListenAddr is where waf listens (SWW_LISTEN_ADDR).
// ListenAddr is where waf listens (SWW_WAF_LISTEN_ADDR).
ListenAddr string
// InstanceName is the name every log line and alert gives as instance,
// and every metric carries as its label instance (SWW_INSTANCE_NAME),
// and every metric carries as its label instance (SWW_WAF_INSTANCE_NAME),
// by default the host's name, which docker sets to the first 12
// characters of the container's id.
InstanceName string
// Observe is true in observe mode, when SWW_MODE is observe rather
// than enforce: a request that SWW_DENY_NETS, a ban, the country
// Observe is true in observe mode, when SWW_WAF_MODE is observe rather
// than enforce: a request that SWW_WAF_DENY_NETS, a ban, the country
// lists, a rate limit, a rule or the Core Rule Set would refuse is
// allowed instead, and no ban is made.
Observe bool
// IPv6GroupPrefix is the length of the IPv6 netblock that is one client
// (SWW_IPV6_GROUP_PREFIX), from 32 to 128.
// (SWW_WAF_IPV6_GROUP_PREFIX), from 32 to 128.
IPv6GroupPrefix int
// MaxTrackedClients is the most clients the table of clients holds, in
// memory and in clients.json (SWW_MAX_TRACKED_CLIENTS).
// memory and in clients.json (SWW_WAF_MAX_TRACKED_CLIENTS).
MaxTrackedClients int
// AllowNets are the netblocks whose clients skip every check
// (SWW_ALLOW_NETS). RateLimitExemptNets are those whose clients the
// rate limits neither count nor refuse (SWW_RATE_LIMIT_EXEMPT_NETS).
// (SWW_WAF_ALLOW_NETS). RateLimitExemptNets are those whose clients the
// rate limits neither count nor refuse (SWW_WAF_RATE_LIMIT_EXEMPT_NETS).
// DenyNets are those whose clients are always refused
// (SWW_DENY_NETS).
// (SWW_WAF_DENY_NETS).
AllowNets []netip.Prefix
RateLimitExemptNets []netip.Prefix
DenyNets []netip.Prefix
// RateLimitPerMinute, RateLimitPerHour and RateLimitPerDay are the
// most requests a client may make in a minute, an hour and a day
// (SWW_RATE_LIMIT_PER_MINUTE, SWW_RATE_LIMIT_PER_HOUR and
// SWW_RATE_LIMIT_PER_DAY).
// (SWW_WAF_RATE_LIMIT_PER_MINUTE, SWW_WAF_RATE_LIMIT_PER_HOUR and
// SWW_WAF_RATE_LIMIT_PER_DAY).
RateLimitPerMinute int64
RateLimitPerHour int64
RateLimitPerDay int64
// RateLimitExemptPaths are the path prefixes whose requests the rate
// limits neither count nor refuse (SWW_RATE_LIMIT_EXEMPT_PATHS).
// limits neither count nor refuse (SWW_WAF_RATE_LIMIT_EXEMPT_PATHS).
// Each starts with /.
RateLimitExemptPaths []string
// BytesLimitPerMinute, BytesLimitPerHour and BytesLimitPerDay are the
// most bytes a client's requests may carry in a minute, an hour and a
// day (SWW_BYTES_LIMIT_PER_MINUTE, SWW_BYTES_LIMIT_PER_HOUR and
// SWW_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count
// toward them (SWW_BYTES_COUNT): response, request or both.
// day (SWW_WAF_BYTES_LIMIT_PER_MINUTE, SWW_WAF_BYTES_LIMIT_PER_HOUR and
// SWW_WAF_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count
// toward them (SWW_WAF_BYTES_COUNT): response, request or both.
BytesLimitPerMinute int64
BytesLimitPerHour int64
BytesLimitPerDay int64
BytesCount string
// LookupSource is where each client's AS number and country are
// looked up (SWW_LOOKUP_SOURCE): geojs, file, or off for nowhere.
// looked up (SWW_WAF_LOOKUP_SOURCE): geojs, file, or off for nowhere.
// LookupDBPath is the lookup database, the IPinfo Lite file looked up
// in while LookupSource is file (SWW_LOOKUP_DB_PATH), and "" for any
// in while LookupSource is file (SWW_WAF_LOOKUP_DB_PATH), and "" for any
// other source. A request waits up to LookupTimeout for its client's
// first answer from GeoJS while a setting needs it
// (SWW_LOOKUP_TIMEOUT), which cannot be off. AddLookupHeaders is true
// (SWW_WAF_LOOKUP_TIMEOUT), which cannot be off. AddLookupHeaders is true
// when the app is passed the client's AS number and country in headers
// (SWW_ADD_LOOKUP_HEADERS).
// (SWW_WAF_ADD_LOOKUP_HEADERS).
LookupSource string
LookupDBPath string
LookupTimeout time.Duration
AddLookupHeaders bool
// ExclusivelyAllowedCountries, when not empty, are the only countries
// whose clients are let through (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES),
// whose clients are let through (SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES),
// two-letter codes in capitals, as GeoJS gives them.
ExclusivelyAllowedCountries []string
// TagLists are the address lists that each add a tag to the requests of
// the clients they hold (SWW_TAG_LISTS). TagDeny are the tags whose
// requests are refused (SWW_TAG_DENY). TagLimitPercent gives the clients
// the clients they hold (SWW_WAF_TAG_LISTS). TagDeny are the tags whose
// requests are refused (SWW_WAF_TAG_DENY). TagLimitPercent gives the clients
// with a tag that percentage of every rate limit and byte limit, from 1
// to 99 (SWW_TAG_LIMIT_PERCENT). All hold tags as requests carry them: in
// to 99 (SWW_WAF_TAG_LIMIT_PERCENT). All hold tags as requests carry them: in
// lower case, a country code in capitals.
TagLists []TagList
TagDeny []string
TagLimitPercent map[string]int64
// RouteWatchURL is where the routewatch instance is that adds the asn=
// and prefix= tags, "" for none (SWW_ROUTEWATCH_URL).
// and prefix= tags, "" for none (SWW_WAF_ROUTEWATCH_URL).
RouteWatchURL string
// The biased thresholds. ASNBytesPercent and CountryBytesPercent give
// those they list a percentage of the byte limits in place of the one
// TagLimitPercent gives their asn= or country= tag
// (SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT). Each holds
// (SWW_WAF_ASN_BYTES_PERCENT and SWW_WAF_COUNTRY_BYTES_PERCENT). Each holds
// percentages from 0 to 100, by AS number, written as AS64496, or by
// country, a two-letter code in capitals, as the lookup gives them.
// UnknownLimitPercent is the percentage of every limit a client without
// a country gets (SWW_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
// a country gets (SWW_WAF_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
// where a file of AS:percent lines is fetched from, whose percentages
// count like asn= tags in TagLimitPercent (SWW_ASN_LIMIT_PERCENT_URL),
// count like asn= tags in TagLimitPercent (SWW_WAF_ASN_LIMIT_PERCENT_URL),
// "" while it is unset.
ASNBytesPercent map[string]int64
CountryBytesPercent map[string]int64
UnknownLimitPercent int64
ASNLimitPercentURL string
// BlocklistURLs are where the blocklists are fetched from
// (SWW_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is
// (SWW_WAF_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is
// fetched again BlocklistRefresh after it was last fetched or tried
// (SWW_BLOCKLIST_REFRESH), which is never less than an hour.
// (SWW_WAF_BLOCKLIST_REFRESH), which is never less than an hour.
// BlocklistAction is what is done with a client a blocklist lists
// (SWW_BLOCKLIST_ACTION): deny, limit or log; for limit,
// (SWW_WAF_BLOCKLIST_ACTION): deny, limit or log; for limit,
// BlocklistLimitPercent is the percentage of every limit it gets.
BlocklistURLs []string
BlocklistRefresh time.Duration
BlocklistAction string
BlocklistLimitPercent int64
// DNSBLZones are the DNSBL zones clients are asked about
// (SWW_DNSBL_ZONES), through DNSBLResolver (SWW_DNSBL_RESOLVER), or
// (SWW_WAF_DNSBL_ZONES), through DNSBLResolver (SWW_WAF_DNSBL_RESOLVER), or
// the host's resolver while that is the zero AddrPort.
// AbuseIPDBKey is the key of the AbuseIPDB account clients are checked
// with (SWW_ABUSEIPDB_KEY), "" while it is unset and none is. A score
// of AbuseIPDBMinScore or more is a hit (SWW_ABUSEIPDB_MIN_SCORE), and
// with (SWW_WAF_ABUSEIPDB_KEY), "" while it is unset and none is. A score
// of AbuseIPDBMinScore or more is a hit (SWW_WAF_ABUSEIPDB_MIN_SCORE), and
// at most AbuseIPDBDailyBudget checks are made a day
// (SWW_ABUSEIPDB_DAILY_BUDGET).
// (SWW_WAF_ABUSEIPDB_DAILY_BUDGET).
// ReputationAction is what is done with a client a zone's verdict lists,
// or whose score is a hit (SWW_REPUTATION_ACTION): deny, limit or log;
// or whose score is a hit (SWW_WAF_REPUTATION_ACTION): deny, limit or log;
// for limit, ReputationLimitPercent is the percentage of every limit it
// gets. A verdict or a score is used for ReputationCacheTTL after it was
// fetched (SWW_REPUTATION_CACHE_TTL), and a query or a check may take
// ReputationTimeout (SWW_REPUTATION_TIMEOUT). Neither can be off.
// fetched (SWW_WAF_REPUTATION_CACHE_TTL), and a query or a check may take
// ReputationTimeout (SWW_WAF_REPUTATION_TIMEOUT). Neither can be off.
DNSBLZones []string
DNSBLResolver netip.AddrPort
AbuseIPDBKey string
@@ -160,74 +161,77 @@ type Config struct {
ReputationCacheTTL time.Duration
ReputationTimeout time.Duration
// CrowdSecDecisionsURL is where the decision list of the CrowdSec
// engine whose local API SWW_CROWDSEC_LAPI_URL names is fetched from:
// engine whose local API SWW_WAF_CROWDSEC_LAPI_URL names is fetched from:
// that URL with v1/decisions added to its path, "" while it is unset and
// none is. CrowdSecKey is the key the engine is asked with
// (SWW_CROWDSEC_LAPI_KEY).
// (SWW_WAF_CROWDSEC_LAPI_KEY).
CrowdSecDecisionsURL string
CrowdSecKey string
// BanResponse is the status a refused client is answered with, 403
// or 429, or 0 to close the connection without an answer
// (SWW_BAN_RESPONSE). It answers a banned client, a request that
// breaks a rate limit or matches a ban rule, SWW_DENY_NETS,
// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY.
// (SWW_WAF_BAN_RESPONSE). It answers a banned client, a request that
// breaks a rate limit or matches a ban rule, SWW_WAF_DENY_NETS,
// SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_WAF_TAG_DENY.
BanResponse int
// LimitBanDuration is the ban for a first broken rate limit
// (SWW_LIMIT_BAN_DURATION). A limit broken again within
// (SWW_WAF_LIMIT_BAN_DURATION). A limit broken again within
// LimitBanRepeatWindow after the last ban ended bans for three times
// as long as that ban (SWW_LIMIT_BAN_REPEAT_WINDOW), and a ban that
// as long as that ban (SWW_WAF_LIMIT_BAN_REPEAT_WINDOW), and a ban that
// would be longer than MaxBanDuration is permanent instead
// (SWW_MAX_BAN_DURATION). None of them can be off.
// (SWW_WAF_MAX_BAN_DURATION). None of them can be off.
LimitBanDuration time.Duration
LimitBanRepeatWindow time.Duration
MaxBanDuration time.Duration
// AttackBanDuration is the ban for a first clear sign of attack
// (SWW_ATTACK_BAN_DURATION). It cannot be off.
// (SWW_WAF_ATTACK_BAN_DURATION). It cannot be off.
AttackBanDuration time.Duration
// MaxBans is the most bans held (SWW_MAX_BANS).
// MaxBans is the most bans held (SWW_WAF_MAX_BANS).
MaxBans int
// BanScopeV4Prefix is the length of the netblock around an IPv4
// client that a ban covers (SWW_BAN_SCOPE_V4_PREFIX).
// client that a ban covers (SWW_WAF_BAN_SCOPE_V4_PREFIX).
BanScopeV4Prefix int
// StateDir is the directory of the state files, an absolute path
// (SWW_STATE_DIR). bans.json is written StateWriteDelay after a ban
// is made (SWW_STATE_WRITE_DELAY), and every state file every
// StateCounterInterval (SWW_STATE_COUNTER_INTERVAL). Neither can be
// off.
// (SWW_WAF_STATE_DIR). bans.json is written StateWriteDelay after a ban
// is made (SWW_WAF_STATE_WRITE_DELAY), and every state and cache file
// every StateCounterInterval (SWW_WAF_STATE_COUNTER_INTERVAL). Neither
// can be off.
StateDir string
StateWriteDelay time.Duration
StateCounterInterval time.Duration
// CacheDir is the directory of the cache files, which keep answers
// from outside services, an absolute path (SWW_WAF_CACHE_DIR).
CacheDir string
// LogRequestHeaders are the request headers whose values the request
// log gives, in lower case (SWW_LOG_REQUEST_HEADERS).
// log gives, in lower case (SWW_WAF_LOG_REQUEST_HEADERS).
LogRequestHeaders []string
// LogLevel is the least severe of the process's own messages that are
// written (SWW_LOG_LEVEL). It holds back no request log line.
// written (SWW_WAF_LOG_LEVEL). It holds back no request log line.
LogLevel slog.Level
// AdminToken is the bearer token an admin sends for the ban endpoints
// and /_sww/clients/<ip> (SWW_ADMIN_TOKEN), "" while it is
// and /_sww/clients/<ip> (SWW_WAF_ADMIN_TOKEN), "" while it is
// unset and they are off.
AdminToken string
// MetricsToken is the bearer token a scraper sends for the metrics
// (SWW_METRICS_TOKEN), "" while it is unset and the metrics are off.
// (SWW_WAF_METRICS_TOKEN), "" while it is unset and the metrics are off.
// MetricsTopN is how many AS numbers and how many countries get series
// of their own in the metrics (SWW_METRICS_TOP_N).
// of their own in the metrics (SWW_WAF_METRICS_TOP_N).
MetricsToken string
MetricsTopN int
// RulesDir is the directory of the rule files (SWW_RULES_DIR), read
// unless RulesEnabled is false (SWW_RULES_ENABLED).
// RulesDir is the directory of the rule files (SWW_WAF_RULES_DIR), read
// unless RulesEnabled is false (SWW_WAF_RULES_ENABLED).
RulesDir string
RulesEnabled bool
// WAFMode is what the Core Rule Set does (SWW_WAF_MODE): WAFModeOff,
// WAFMode is what the Core Rule Set does (SWW_WAF_CORE_RULE_SET_MODE): WAFModeOff,
// WAFModeDetect or WAFModeBlock. WAFParanoiaLevel is its paranoia
// level, from 1 to 4 (SWW_WAF_PARANOIA_LEVEL), and
// level, from 1 to 4 (SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL), and
// WAFAnomalyThreshold the anomaly score at which a request is a match
// (SWW_WAF_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules
// are the ids of its rules switched off (SWW_WAF_DISABLED_RULES),
// (SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules
// are the ids of its rules switched off (SWW_WAF_CORE_RULE_SET_DISABLED_RULES),
// WAFExemptPaths the path prefixes it does not inspect
// (SWW_WAF_EXEMPT_PATHS), and WAFBodyLimit the most of a request body
// it reads (SWW_WAF_BODY_LIMIT), 0 while it is off and it reads none.
// (SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS), and WAFBodyLimit the most of a request body
// it reads (SWW_WAF_CORE_RULE_SET_BODY_LIMIT), 0 while it is off and it reads none.
// CoreRuleSetExclusionsFile is the path of the file of its exclusions
// (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while there is none.
// (SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while there is none.
WAFMode string
WAFParanoiaLevel int
WAFAnomalyThreshold int
@@ -236,21 +240,21 @@ type Config struct {
WAFBodyLimit int64
CoreRuleSetExclusionsFile string
// TrapPaths are the paths a request for which is a clear sign of
// attack (SWW_TRAP_PATHS), each starting with / and without a ?.
// attack (SWW_WAF_TRAP_PATHS), each starting with / and without a ?.
TrapPaths []string
// ErrorBurstThreshold is the most requests of a client within a minute
// that waf may refuse after a rule file or Core Rule Set match; one
// more breaks a limit (SWW_ERROR_BURST_THRESHOLD). 0 is off.
// more breaks a limit (SWW_WAF_ERROR_BURST_THRESHOLD). 0 is off.
ErrorBurstThreshold int64
// LogRemoteURL is where every line on stdout is also sent
// (SWW_LOG_REMOTE_URL), nil while it is unset and nothing is sent.
// (SWW_WAF_LOG_REMOTE_URL), nil while it is unset and nothing is sent.
// LogRemoteTLSCAs are the certificates a syslog+tls endpoint's
// certificate must chain to (SWW_LOG_REMOTE_TLS_CA_FILE), nil while
// certificate must chain to (SWW_WAF_LOG_REMOTE_TLS_CA_FILE), nil while
// it is unset and the host's own are used. LogRemoteBuffer is the most
// lines held while they wait to be sent (SWW_LOG_REMOTE_BUFFER).
// lines held while they wait to be sent (SWW_WAF_LOG_REMOTE_BUFFER).
// LogRemoteFacility is the number of the syslog facility
// (SWW_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME
// (SWW_LOG_REMOTE_APP_NAME, by default InstanceName), of the records
// (SWW_WAF_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME
// (SWW_WAF_LOG_REMOTE_APP_NAME, by default InstanceName), of the records
// the lines are sent in.
LogRemoteURL *url.URL
LogRemoteTLSCAs *x509.CertPool
@@ -258,18 +262,18 @@ type Config struct {
LogRemoteFacility int
LogRemoteAppName string
// AlertWebhookURL is where each alert is posted as JSON
// (SWW_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders
// are sent with each (SWW_ALERT_WEBHOOK_HEADERS).
// (SWW_WAF_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders
// are sent with each (SWW_WAF_ALERT_WEBHOOK_HEADERS).
// AlertSlackWebhookURL is the Slack incoming webhook each alert is
// posted to as a message (SWW_ALERT_SLACK_WEBHOOK_URL), and
// posted to as a message (SWW_WAF_ALERT_SLACK_WEBHOOK_URL), and
// AlertNtfyURL the ntfy topic each is published to
// (SWW_ALERT_NTFY_URL), each nil while it is unset; AlertNtfyToken,
// unless empty, is sent to ntfy with each (SWW_ALERT_NTFY_TOKEN).
// (SWW_WAF_ALERT_NTFY_URL), each nil while it is unset; AlertNtfyToken,
// unless empty, is sent to ntfy with each (SWW_WAF_ALERT_NTFY_TOKEN).
// With none of the three URLs set, no alert is sent. AlertEvents are
// the events alerts are sent for (SWW_ALERT_EVENTS). A repeat of an
// alert within AlertCooldown is held back (SWW_ALERT_COOLDOWN), and
// the events alerts are sent for (SWW_WAF_ALERT_EVENTS). A repeat of an
// alert within AlertCooldown is held back (SWW_WAF_ALERT_COOLDOWN), and
// so is an alert past AlertMaxPerHour in an hour, for the hour's
// summary (SWW_ALERT_MAX_PER_HOUR); 0 is off for both.
// summary (SWW_WAF_ALERT_MAX_PER_HOUR); 0 is off for both.
AlertWebhookURL *url.URL
AlertWebhookHeaders http.Header
AlertSlackWebhookURL *url.URL
@@ -279,14 +283,14 @@ type Config struct {
AlertCooldown time.Duration
AlertMaxPerHour int
// The anomaly thresholds, which only raise alerts: the most requests
// and bytes a minute and an hour per client (SWW_ANOMALY_CLIENT_*),
// per netblock around a client (SWW_ANOMALY_NET_*), per AS number
// (SWW_ANOMALY_ASN_*), for the whole service (SWW_ANOMALY_TOTAL_*)
// and per named netblock (SWW_WATCH_*), each 0 while it is off.
// and bytes a minute and an hour per client (SWW_WAF_ANOMALY_CLIENT_*),
// per netblock around a client (SWW_WAF_ANOMALY_NET_*), per AS number
// (SWW_WAF_ANOMALY_ASN_*), for the whole service (SWW_WAF_ANOMALY_TOTAL_*)
// and per named netblock (SWW_WAF_WATCH_*), each 0 while it is off.
// AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths of the
// netblock around a client (SWW_ANOMALY_NET_V4_PREFIX and
// SWW_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks
// (SWW_WATCH_NETS).
// netblock around a client (SWW_WAF_ANOMALY_NET_V4_PREFIX and
// SWW_WAF_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks
// (SWW_WAF_WATCH_NETS).
AnomalyClient anomaly.Thresholds
AnomalyNet anomaly.Thresholds
AnomalyASN anomaly.Thresholds
@@ -305,7 +309,7 @@ type Config struct {
// off.
const off = "off"
// The values of SWW_WAF_MODE.
// The values of SWW_WAF_CORE_RULE_SET_MODE.
const (
// WAFModeOff runs no request through the Core Rule Set.
WAFModeOff = off
@@ -315,8 +319,8 @@ const (
WAFModeBlock = "block"
)
// fileSource is the SWW_LOOKUP_SOURCE that looks clients up in the
// lookup database, the file SWW_LOOKUP_DB_PATH names.
// fileSource is the SWW_WAF_LOOKUP_SOURCE that looks clients up in the
// lookup database, the file SWW_WAF_LOOKUP_DB_PATH names.
const fileSource = "file"
const (
@@ -326,7 +330,7 @@ const (
gibibyte = 1 << 30
ipv4Bits = 32
ipv6Bits = 128
// minIPv6GroupPrefix is the shortest SWW_IPV6_GROUP_PREFIX, the
// minIPv6GroupPrefix is the shortest SWW_WAF_IPV6_GROUP_PREFIX, the
// netblock a provider is usually given: a shorter one would make one
// client of the customers of several providers.
minIPv6GroupPrefix = 32
@@ -343,8 +347,8 @@ const (
// masked is what the log shows for a token that is set, and in place of
// a secret in another setting.
masked = "********"
// defaultListenAddr is the default of SWW_LISTEN_ADDR and
// SWRP_LISTEN_ADDR, and defaultUpstreamURL that of SWRP_UPSTREAM_URL.
// defaultListenAddr is the default of SWW_WAF_LISTEN_ADDR and
// SWW_RPROXY_LISTEN_ADDR, and defaultUpstreamURL that of SWW_RPROXY_UPSTREAM_URL.
defaultListenAddr = ":8080"
defaultUpstreamURL = "http://127.0.0.1:8081"
)
@@ -372,7 +376,7 @@ var (
errHeaderTakenOut = errors.New(
"is taken out of every request by Go's HTTP server, so it can never " +
"be logged")
errOnBothLists = errors.New("is in SWW_TAG_DENY too")
errOnBothLists = errors.New("is in SWW_WAF_TAG_DENY too")
errNotLookupSource = errors.New("is not geojs, file or off")
errNeedsLookups = errors.New("it needs each client looked up")
errNeedsDBPath = errors.New("it names the file to look clients up in")
@@ -448,7 +452,7 @@ var (
errNotListURL = errors.New(
"is not an http or https URL without a user or a fragment, " +
"such as https://www.spamhaus.org/drop/drop.txt")
errInBlocklistURLs = errors.New("is in SWW_BLOCKLIST_URLS too")
errInBlocklistURLs = errors.New("is in SWW_WAF_BLOCKLIST_URLS too")
errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h")
errNotAction = errors.New(
"is not deny, limit:<percent> such as limit:25, or log")
@@ -472,9 +476,9 @@ var (
errNeedsLAPIKey = errors.New("the engine answers no request without it")
errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
errAnotherList = errors.New(
"is in SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too")
errFetchedOtherwise = errors.New("is in SWW_BLOCKLIST_URLS, is " +
"SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too")
"is in SWW_WAF_BLOCKLIST_URLS or is SWW_WAF_ASN_LIMIT_PERCENT_URL too")
errFetchedOtherwise = errors.New("is in SWW_WAF_BLOCKLIST_URLS, is " +
"SWW_WAF_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too")
errNotTagList = errors.New(
"is not a name, = and an http or https URL, such as " +
"torexit=https://example.com/tor.txt")
@@ -490,124 +494,124 @@ var (
errCrowdSecTag = errors.New(
"cannot be used: a CrowdSec decision refuses the request first")
errNoRequestGetsTag = errors.New(
"is a tag no request can get: it is not a name in SWW_TAG_LISTS, an " +
"asn= or country= tag, a dnsbl= tag of a zone in SWW_DNSBL_ZONES, " +
"a prefix= tag with SWW_ROUTEWATCH_URL set, abuseipdb with " +
"SWW_ABUSEIPDB_KEY set, or exploitsignature with rule files or the " +
"is a tag no request can get: it is not a name in SWW_WAF_TAG_LISTS, an " +
"asn= or country= tag, a dnsbl= tag of a zone in SWW_WAF_DNSBL_ZONES, " +
"a prefix= tag with SWW_WAF_ROUTEWATCH_URL set, abuseipdb with " +
"SWW_WAF_ABUSEIPDB_KEY set, or exploitsignature with rule files or the " +
"Core Rule Set on")
)
// FromEnvironment reads the settings with lookupEnv, normally
// os.LookupEnv. A setting may instead be given as a file: the variable
// named by the setting's name with _FILE added names the file, which is
// read now (see lookup). A setting that is not set takes its default. A
// setting that is set but invalid is an error that names it.
// FromEnvironment reads the settings through v, normally the viper
// NewViper returns, bound to the process's environment. A setting may
// instead be given as a file: the variable named by the setting's name
// with _FILE added names the file, which is read now (see lookup). A
// setting that is not set takes its default. A setting that is set but
// invalid is an error that names it.
//
//nolint:funlen // one line for each setting, a list that grows with them
func FromEnvironment(
lookupEnv func(string) (string, bool),
) (*Config, error) {
env := &environment{lookupEnv: lookupEnv}
func FromEnvironment(v *viper.Viper) (*Config, error) {
env := &environment{v: v}
cfg := &Config{
ListenAddr: env.address("SWW_LISTEN_ADDR"),
ListenAddr: env.address("SWW_WAF_LISTEN_ADDR"),
InstanceName: env.instanceName(),
Observe: env.observe("SWW_MODE", "enforce"),
IPv6GroupPrefix: env.ipv6GroupPrefix("SWW_IPV6_GROUP_PREFIX", "64"),
MaxTrackedClients: env.numberNotOff("SWW_MAX_TRACKED_CLIENTS", "20000"),
AllowNets: env.netblocks("SWW_ALLOW_NETS", ""),
RateLimitExemptNets: env.netblocks("SWW_RATE_LIMIT_EXEMPT_NETS", ""),
DenyNets: env.netblocks("SWW_DENY_NETS", ""),
RateLimitPerMinute: env.count("SWW_RATE_LIMIT_PER_MINUTE", "120"),
RateLimitPerHour: env.count("SWW_RATE_LIMIT_PER_HOUR", "1500"),
RateLimitPerDay: env.count("SWW_RATE_LIMIT_PER_DAY", "6000"),
RateLimitExemptPaths: env.pathPrefixes("SWW_RATE_LIMIT_EXEMPT_PATHS", ""),
BytesLimitPerMinute: env.size("SWW_BYTES_LIMIT_PER_MINUTE", "off"),
BytesLimitPerHour: env.size("SWW_BYTES_LIMIT_PER_HOUR", "50G"),
BytesLimitPerDay: env.size("SWW_BYTES_LIMIT_PER_DAY", "100G"),
BytesCount: env.bytesCount("SWW_BYTES_COUNT", "both"),
LookupSource: env.lookupSource("SWW_LOOKUP_SOURCE", "geojs"),
LookupDBPath: env.value("SWW_LOOKUP_DB_PATH", ""),
LookupTimeout: env.durationNotOff("SWW_LOOKUP_TIMEOUT", "800ms"),
AddLookupHeaders: env.boolean("SWW_ADD_LOOKUP_HEADERS", "false"),
Observe: env.observe("SWW_WAF_MODE", "enforce"),
IPv6GroupPrefix: env.ipv6GroupPrefix("SWW_WAF_IPV6_GROUP_PREFIX", "64"),
MaxTrackedClients: env.numberNotOff("SWW_WAF_MAX_TRACKED_CLIENTS", "20000"),
AllowNets: env.netblocks("SWW_WAF_ALLOW_NETS", ""),
RateLimitExemptNets: env.netblocks("SWW_WAF_RATE_LIMIT_EXEMPT_NETS", ""),
DenyNets: env.netblocks("SWW_WAF_DENY_NETS", ""),
RateLimitPerMinute: env.count("SWW_WAF_RATE_LIMIT_PER_MINUTE", "120"),
RateLimitPerHour: env.count("SWW_WAF_RATE_LIMIT_PER_HOUR", "1500"),
RateLimitPerDay: env.count("SWW_WAF_RATE_LIMIT_PER_DAY", "6000"),
RateLimitExemptPaths: env.pathPrefixes("SWW_WAF_RATE_LIMIT_EXEMPT_PATHS", ""),
BytesLimitPerMinute: env.size("SWW_WAF_BYTES_LIMIT_PER_MINUTE", "off"),
BytesLimitPerHour: env.size("SWW_WAF_BYTES_LIMIT_PER_HOUR", "50G"),
BytesLimitPerDay: env.size("SWW_WAF_BYTES_LIMIT_PER_DAY", "100G"),
BytesCount: env.bytesCount("SWW_WAF_BYTES_COUNT", "both"),
LookupSource: env.lookupSource("SWW_WAF_LOOKUP_SOURCE", "geojs"),
LookupDBPath: env.value("SWW_WAF_LOOKUP_DB_PATH", ""),
LookupTimeout: env.durationNotOff("SWW_WAF_LOOKUP_TIMEOUT", "800ms"),
AddLookupHeaders: env.boolean("SWW_WAF_ADD_LOOKUP_HEADERS", "false"),
ExclusivelyAllowedCountries: env.countries(
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
TagLists: env.tagLists("SWW_TAG_LISTS"),
TagDeny: env.tags("SWW_TAG_DENY"),
TagLimitPercent: env.tagPercents("SWW_TAG_LIMIT_PERCENT"),
RouteWatchURL: env.routeWatchURL("SWW_ROUTEWATCH_URL"),
ASNBytesPercent: env.percents("SWW_ASN_BYTES_PERCENT", ParseASN),
CountryBytesPercent: env.percents("SWW_COUNTRY_BYTES_PERCENT", parseCountry),
UnknownLimitPercent: env.percent("SWW_UNKNOWN_LIMIT_PERCENT", "100"),
ASNLimitPercentURL: env.listURL("SWW_ASN_LIMIT_PERCENT_URL"),
BlocklistURLs: env.listURLs("SWW_BLOCKLIST_URLS"),
BlocklistRefresh: env.refresh("SWW_BLOCKLIST_REFRESH", "24h"),
DNSBLZones: env.zones("SWW_DNSBL_ZONES"),
DNSBLResolver: env.resolver("SWW_DNSBL_RESOLVER"),
AbuseIPDBKey: env.secret("SWW_ABUSEIPDB_KEY"),
AbuseIPDBMinScore: env.percent("SWW_ABUSEIPDB_MIN_SCORE", "75"),
AbuseIPDBDailyBudget: env.numberNotOff("SWW_ABUSEIPDB_DAILY_BUDGET", "900"),
ReputationCacheTTL: env.durationNotOff("SWW_REPUTATION_CACHE_TTL", "24h"),
ReputationTimeout: env.durationNotOff("SWW_REPUTATION_TIMEOUT", "2s"),
CrowdSecDecisionsURL: env.crowdSecDecisionsURL("SWW_CROWDSEC_LAPI_URL"),
CrowdSecKey: env.secret("SWW_CROWDSEC_LAPI_KEY"),
BanResponse: env.banResponse("SWW_BAN_RESPONSE", "403"),
LimitBanDuration: env.durationNotOff("SWW_LIMIT_BAN_DURATION", "1h"),
LimitBanRepeatWindow: env.durationNotOff("SWW_LIMIT_BAN_REPEAT_WINDOW", "24h"),
MaxBanDuration: env.durationNotOff("SWW_MAX_BAN_DURATION", "7d"),
AttackBanDuration: env.durationNotOff("SWW_ATTACK_BAN_DURATION", "7d"),
MaxBans: env.numberNotOff("SWW_MAX_BANS", "5000"),
BanScopeV4Prefix: env.v4Prefix("SWW_BAN_SCOPE_V4_PREFIX", "32"),
StateDir: env.absolutePath("SWW_STATE_DIR", "/var/lib/sww"),
StateWriteDelay: env.durationNotOff("SWW_STATE_WRITE_DELAY", "10s"),
StateCounterInterval: env.durationNotOff("SWW_STATE_COUNTER_INTERVAL", "15m"),
LogRequestHeaders: env.headerNames("SWW_LOG_REQUEST_HEADERS",
"SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
TagLists: env.tagLists("SWW_WAF_TAG_LISTS"),
TagDeny: env.tags("SWW_WAF_TAG_DENY"),
TagLimitPercent: env.tagPercents("SWW_WAF_TAG_LIMIT_PERCENT"),
RouteWatchURL: env.routeWatchURL("SWW_WAF_ROUTEWATCH_URL"),
ASNBytesPercent: env.percents("SWW_WAF_ASN_BYTES_PERCENT", ParseASN),
CountryBytesPercent: env.percents("SWW_WAF_COUNTRY_BYTES_PERCENT", parseCountry),
UnknownLimitPercent: env.percent("SWW_WAF_UNKNOWN_LIMIT_PERCENT", "100"),
ASNLimitPercentURL: env.listURL("SWW_WAF_ASN_LIMIT_PERCENT_URL"),
BlocklistURLs: env.listURLs("SWW_WAF_BLOCKLIST_URLS"),
BlocklistRefresh: env.refresh("SWW_WAF_BLOCKLIST_REFRESH", "24h"),
DNSBLZones: env.zones("SWW_WAF_DNSBL_ZONES"),
DNSBLResolver: env.resolver("SWW_WAF_DNSBL_RESOLVER"),
AbuseIPDBKey: env.secret("SWW_WAF_ABUSEIPDB_KEY"),
AbuseIPDBMinScore: env.percent("SWW_WAF_ABUSEIPDB_MIN_SCORE", "75"),
AbuseIPDBDailyBudget: env.numberNotOff("SWW_WAF_ABUSEIPDB_DAILY_BUDGET", "900"),
ReputationCacheTTL: env.durationNotOff("SWW_WAF_REPUTATION_CACHE_TTL", "24h"),
ReputationTimeout: env.durationNotOff("SWW_WAF_REPUTATION_TIMEOUT", "2s"),
CrowdSecDecisionsURL: env.crowdSecDecisionsURL("SWW_WAF_CROWDSEC_LAPI_URL"),
CrowdSecKey: env.secret("SWW_WAF_CROWDSEC_LAPI_KEY"),
BanResponse: env.banResponse("SWW_WAF_BAN_RESPONSE", "403"),
LimitBanDuration: env.durationNotOff("SWW_WAF_LIMIT_BAN_DURATION", "1h"),
LimitBanRepeatWindow: env.durationNotOff("SWW_WAF_LIMIT_BAN_REPEAT_WINDOW", "24h"),
MaxBanDuration: env.durationNotOff("SWW_WAF_MAX_BAN_DURATION", "7d"),
AttackBanDuration: env.durationNotOff("SWW_WAF_ATTACK_BAN_DURATION", "7d"),
MaxBans: env.numberNotOff("SWW_WAF_MAX_BANS", "5000"),
BanScopeV4Prefix: env.v4Prefix("SWW_WAF_BAN_SCOPE_V4_PREFIX", "32"),
StateDir: env.absolutePath("SWW_WAF_STATE_DIR", "/var/lib/sww"),
StateWriteDelay: env.durationNotOff("SWW_WAF_STATE_WRITE_DELAY", "10s"),
StateCounterInterval: env.durationNotOff("SWW_WAF_STATE_COUNTER_INTERVAL", "15m"),
CacheDir: env.absolutePath("SWW_WAF_CACHE_DIR", "/var/cache/sww"),
LogRequestHeaders: env.headerNames("SWW_WAF_LOG_REQUEST_HEADERS",
"accept,accept-language,accept-encoding,content-type,origin,range"),
LogLevel: env.logLevel("SWW_LOG_LEVEL", "info"),
AdminToken: env.token("SWW_ADMIN_TOKEN"),
MetricsToken: env.token("SWW_METRICS_TOKEN"),
MetricsTopN: env.numberNotOff("SWW_METRICS_TOP_N", "50"),
RulesDir: env.value("SWW_RULES_DIR", "/etc/sww/rules.d"),
RulesEnabled: env.boolean("SWW_RULES_ENABLED", "true"),
WAFMode: env.wafMode("SWW_WAF_MODE", WAFModeBlock),
WAFParanoiaLevel: env.paranoiaLevel("SWW_WAF_PARANOIA_LEVEL", "1"),
WAFAnomalyThreshold: env.numberOrOff("SWW_WAF_ANOMALY_THRESHOLD", "5"),
WAFDisabledRules: env.ruleIDs("SWW_WAF_DISABLED_RULES",
LogLevel: env.logLevel("SWW_WAF_LOG_LEVEL", "info"),
AdminToken: env.token("SWW_WAF_ADMIN_TOKEN"),
MetricsToken: env.token("SWW_WAF_METRICS_TOKEN"),
MetricsTopN: env.numberNotOff("SWW_WAF_METRICS_TOP_N", "50"),
RulesDir: env.value("SWW_WAF_RULES_DIR", "/etc/sww/rules.d"),
RulesEnabled: env.boolean("SWW_WAF_RULES_ENABLED", "true"),
WAFMode: env.wafMode("SWW_WAF_CORE_RULE_SET_MODE", WAFModeBlock),
WAFParanoiaLevel: env.paranoiaLevel("SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL", "1"),
WAFAnomalyThreshold: env.numberOrOff("SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD", "5"),
WAFDisabledRules: env.ruleIDs("SWW_WAF_CORE_RULE_SET_DISABLED_RULES",
"920340,920420,920440,920640,930130,930140"),
WAFExemptPaths: env.pathPrefixes("SWW_WAF_EXEMPT_PATHS", ""),
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off),
WAFExemptPaths: env.pathPrefixes("SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS", ""),
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_CORE_RULE_SET_BODY_LIMIT", off),
CoreRuleSetExclusionsFile: env.filePath(
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE"),
TrapPaths: env.trapPaths("SWW_TRAP_PATHS"),
ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"),
LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"),
LogRemoteTLSCAs: env.certificates("SWW_LOG_REMOTE_TLS_CA_FILE"),
LogRemoteBuffer: env.numberNotOff("SWW_LOG_REMOTE_BUFFER", "10000"),
LogRemoteFacility: env.facility("SWW_LOG_REMOTE_FACILITY", "local0"),
AlertWebhookURL: env.webhookURL("SWW_ALERT_WEBHOOK_URL"),
AlertWebhookHeaders: env.webhookHeaders("SWW_ALERT_WEBHOOK_HEADERS"),
AlertSlackWebhookURL: env.webhookURL("SWW_ALERT_SLACK_WEBHOOK_URL"),
AlertNtfyURL: env.webhookURL("SWW_ALERT_NTFY_URL"),
AlertNtfyToken: env.secret("SWW_ALERT_NTFY_TOKEN"),
AlertEvents: env.alertEvents("SWW_ALERT_EVENTS",
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE"),
TrapPaths: env.trapPaths("SWW_WAF_TRAP_PATHS"),
ErrorBurstThreshold: env.count("SWW_WAF_ERROR_BURST_THRESHOLD", "30"),
LogRemoteURL: env.logRemoteURL("SWW_WAF_LOG_REMOTE_URL"),
LogRemoteTLSCAs: env.certificates("SWW_WAF_LOG_REMOTE_TLS_CA_FILE"),
LogRemoteBuffer: env.numberNotOff("SWW_WAF_LOG_REMOTE_BUFFER", "10000"),
LogRemoteFacility: env.facility("SWW_WAF_LOG_REMOTE_FACILITY", "local0"),
AlertWebhookURL: env.webhookURL("SWW_WAF_ALERT_WEBHOOK_URL"),
AlertWebhookHeaders: env.webhookHeaders("SWW_WAF_ALERT_WEBHOOK_HEADERS"),
AlertSlackWebhookURL: env.webhookURL("SWW_WAF_ALERT_SLACK_WEBHOOK_URL"),
AlertNtfyURL: env.webhookURL("SWW_WAF_ALERT_NTFY_URL"),
AlertNtfyToken: env.secret("SWW_WAF_ALERT_NTFY_TOKEN"),
AlertEvents: env.alertEvents("SWW_WAF_ALERT_EVENTS",
strings.Join(alerts.Events(), ",")),
AlertCooldown: env.duration("SWW_ALERT_COOLDOWN", "15m"),
AlertMaxPerHour: env.numberOrOff("SWW_ALERT_MAX_PER_HOUR", "60"),
AnomalyClient: env.thresholds("SWW_ANOMALY_CLIENT_"),
AnomalyNet: env.thresholds("SWW_ANOMALY_NET_"),
AnomalyASN: env.thresholds("SWW_ANOMALY_ASN_"),
AnomalyTotal: env.thresholds("SWW_ANOMALY_TOTAL_"),
AnomalyWatch: env.thresholds("SWW_WATCH_"),
AnomalyNetV4Prefix: env.v4Prefix("SWW_ANOMALY_NET_V4_PREFIX", "24"),
AnomalyNetV6Prefix: env.v6Prefix("SWW_ANOMALY_NET_V6_PREFIX", "48"),
WatchNets: env.namedNetblocks("SWW_WATCH_NETS"),
AlertCooldown: env.duration("SWW_WAF_ALERT_COOLDOWN", "15m"),
AlertMaxPerHour: env.numberOrOff("SWW_WAF_ALERT_MAX_PER_HOUR", "60"),
AnomalyClient: env.thresholds("SWW_WAF_ANOMALY_CLIENT_"),
AnomalyNet: env.thresholds("SWW_WAF_ANOMALY_NET_"),
AnomalyASN: env.thresholds("SWW_WAF_ANOMALY_ASN_"),
AnomalyTotal: env.thresholds("SWW_WAF_ANOMALY_TOTAL_"),
AnomalyWatch: env.thresholds("SWW_WAF_WATCH_"),
AnomalyNetV4Prefix: env.v4Prefix("SWW_WAF_ANOMALY_NET_V4_PREFIX", "24"),
AnomalyNetV6Prefix: env.v6Prefix("SWW_WAF_ANOMALY_NET_V6_PREFIX", "48"),
WatchNets: env.namedNetblocks("SWW_WAF_WATCH_NETS"),
}
cfg.LogRemoteAppName = env.appName("SWW_LOG_REMOTE_APP_NAME",
cfg.LogRemoteAppName = env.appName("SWW_WAF_LOG_REMOTE_APP_NAME",
cfg.InstanceName, cfg.LogRemoteURL != nil)
cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action(
"SWW_BLOCKLIST_ACTION", "deny")
"SWW_WAF_BLOCKLIST_ACTION", "deny")
cfg.ReputationAction, cfg.ReputationLimitPercent = env.action(
"SWW_REPUTATION_ACTION", "limit:25")
"SWW_WAF_REPUTATION_ACTION", "limit:25")
env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil)
env.checkLookupDBPath(cfg)
@@ -629,8 +633,8 @@ func FromEnvironment(
// Command reads only SWW_COMMAND, the command the image runs and its
// health check checks: rproxy when it is unset or empty, or waf. It cannot
// be given as a file. Any other value is an error that names the setting.
func Command(lookupEnv func(string) (string, bool)) (string, error) {
command, _ := lookupEnv("SWW_COMMAND")
func Command(v *viper.Viper) (string, error) {
command, _ := readSetting(v, "SWW_COMMAND")
switch command {
case "", "rproxy":
@@ -642,22 +646,22 @@ func Command(lookupEnv func(string) (string, bool)) (string, error) {
}
}
// WAFListenAddr reads only SWW_LISTEN_ADDR, which may be given as a
// WAFListenAddr reads only SWW_WAF_LISTEN_ADDR, which may be given as a
// file, as FromEnvironment does. The health check in waf's container needs
// no other setting, so it reads no other, nor a file that another names.
func WAFListenAddr(lookupEnv func(string) (string, bool)) (string, error) {
env := &environment{lookupEnv: lookupEnv}
listenAddr := env.address("SWW_LISTEN_ADDR")
func WAFListenAddr(v *viper.Viper) (string, error) {
env := &environment{v: v}
listenAddr := env.address("SWW_WAF_LISTEN_ADDR")
return listenAddr, env.err
}
// InstanceName reads only SWW_INSTANCE_NAME, which may be given as a
// InstanceName reads only SWW_WAF_INSTANCE_NAME, which may be given as a
// file, as FromEnvironment does, so that the line saying a setting is
// invalid carries it too. A file that cannot be read gives the default
// here, and FromEnvironment then stops the start over it.
func InstanceName(lookupEnv func(string) (string, bool)) string {
env := &environment{lookupEnv: lookupEnv}
func InstanceName(v *viper.Viper) string {
env := &environment{v: v}
return env.instanceName()
}
@@ -673,11 +677,23 @@ func (c *Config) LogValue() slog.Value {
}
// environment is where FromEnvironment reads the settings: it notes each
// value for the log, and keeps the first error.
// value for the log, and keeps the first error. It reads every setting
// through v, the viper NewViper returns, and honours each setting's _FILE
// form too.
type environment struct {
lookupEnv func(string) (string, bool)
settings []slog.Attr
err error
v *viper.Viper
settings []slog.Attr
err error
}
// readSetting returns a setting's value and whether it is set, reading it
// through v.
func readSetting(v *viper.Viper, name string) (string, bool) {
if !v.IsSet(name) {
return "", false
}
return v.GetString(name), true
}
// value returns a setting's value, or its default when it is not set,
@@ -698,10 +714,10 @@ func (e *environment) value(name, defaultValue string) string {
// names, less one newline at their end. It notes that file's path for the
// log. Both variables set, or a file that cannot be read, is an error.
func (e *environment) lookup(name string) (string, bool) {
value, set := e.lookupEnv(name)
value, set := readSetting(e.v, name)
fileName := name + "_FILE"
path, inFile := e.lookupEnv(fileName)
path, inFile := readSetting(e.v, fileName)
if !inFile {
return value, set
}
@@ -893,7 +909,7 @@ func (e *environment) percents(
return percents
}
// tagLists reads SWW_TAG_LISTS, which is empty by default.
// tagLists reads SWW_WAF_TAG_LISTS, which is empty by default.
func (e *environment) tagLists(name string) []TagList {
lists, err := parseTagLists(e.value(name, ""))
e.check(name, err)
@@ -1057,32 +1073,32 @@ func (e *environment) lookupSource(name, defaultValue string) string {
return source
}
// checkLookupDBPath refuses SWW_LOOKUP_SOURCE=file without
// SWW_LOOKUP_DB_PATH, and SWW_LOOKUP_DB_PATH with any other source:
// checkLookupDBPath refuses SWW_WAF_LOOKUP_SOURCE=file without
// SWW_WAF_LOOKUP_DB_PATH, and SWW_WAF_LOOKUP_DB_PATH with any other source:
// one source at a time.
func (e *environment) checkLookupDBPath(cfg *Config) {
switch {
case cfg.LookupSource == fileSource && cfg.LookupDBPath == "":
e.check("SWW_LOOKUP_SOURCE", fmt.Errorf(
"is file while SWW_LOOKUP_DB_PATH is unset; %w", errNeedsDBPath))
e.check("SWW_WAF_LOOKUP_SOURCE", fmt.Errorf(
"is file while SWW_WAF_LOOKUP_DB_PATH is unset; %w", errNeedsDBPath))
case cfg.LookupSource != fileSource && cfg.LookupDBPath != "":
e.check("SWW_LOOKUP_DB_PATH", fmt.Errorf(
"is set while SWW_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused))
e.check("SWW_WAF_LOOKUP_DB_PATH", fmt.Errorf(
"is set while SWW_WAF_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused))
}
}
// checkCountriesAndLookups refuses a country in
// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES whose tag is in SWW_TAG_DENY, and,
// while SWW_LOOKUP_SOURCE is off, each setting that needs clients looked
// up: the country list, SWW_ADD_LOOKUP_HEADERS, the tag settings that name
// SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES whose tag is in SWW_WAF_TAG_DENY, and,
// while SWW_WAF_LOOKUP_SOURCE is off, each setting that needs clients looked
// up: the country list, SWW_WAF_ADD_LOOKUP_HEADERS, the tag settings that name
// an asn= or country= tag, the biased thresholds,
// SWW_ASN_LIMIT_PERCENT_URL among them, of which
// SWW_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a
// SWW_WAF_ASN_LIMIT_PERCENT_URL among them, of which
// SWW_WAF_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a
// limit, and the anomaly thresholds per AS number.
func (e *environment) checkCountriesAndLookups(cfg *Config) {
for _, country := range cfg.ExclusivelyAllowedCountries {
if slices.Contains(cfg.TagDeny, CountryTagPrefix+country) {
e.check("SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
e.check("SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES",
fmt.Errorf("%q %w", country, errOnBothLists))
}
}
@@ -1101,77 +1117,77 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
name string
set bool
}{
{"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
{"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
{"SWW_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, onlyLookupGives)},
{"SWW_TAG_LIMIT_PERCENT", slices.ContainsFunc(
{"SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
{"SWW_WAF_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
{"SWW_WAF_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, onlyLookupGives)},
{"SWW_WAF_TAG_LIMIT_PERCENT", slices.ContainsFunc(
slices.Collect(maps.Keys(cfg.TagLimitPercent)), onlyLookupGives)},
{"SWW_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
{"SWW_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
{"SWW_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
{"SWW_ASN_LIMIT_PERCENT_URL", cfg.ASNLimitPercentURL != ""},
{"SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0},
{"SWW_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0},
{"SWW_ANOMALY_ASN_BYTES_PER_MINUTE", cfg.AnomalyASN.BytesPerMinute > 0},
{"SWW_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0},
{"SWW_WAF_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
{"SWW_WAF_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
{"SWW_WAF_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
{"SWW_WAF_ASN_LIMIT_PERCENT_URL", cfg.ASNLimitPercentURL != ""},
{"SWW_WAF_ANOMALY_ASN_REQUESTS_PER_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0},
{"SWW_WAF_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0},
{"SWW_WAF_ANOMALY_ASN_BYTES_PER_MINUTE", cfg.AnomalyASN.BytesPerMinute > 0},
{"SWW_WAF_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0},
} {
if setting.set {
e.check(setting.name, fmt.Errorf("is set while SWW_LOOKUP_SOURCE is off; %w",
e.check(setting.name, fmt.Errorf("is set while SWW_WAF_LOOKUP_SOURCE is off; %w",
errNeedsLookups))
}
}
}
// checkASNLimitPercentURL refuses SWW_ASN_LIMIT_PERCENT_URL naming a
// checkASNLimitPercentURL refuses SWW_WAF_ASN_LIMIT_PERCENT_URL naming a
// blocklist too: the file at a URL is fetched as one list or the other.
func (e *environment) checkASNLimitPercentURL(cfg *Config) {
if slices.Contains(cfg.BlocklistURLs, cfg.ASNLimitPercentURL) {
e.check("SWW_ASN_LIMIT_PERCENT_URL",
e.check("SWW_WAF_ASN_LIMIT_PERCENT_URL",
fmt.Errorf("%q %w", cfg.ASNLimitPercentURL, errInBlocklistURLs))
}
}
// checkCrowdSec refuses SWW_CROWDSEC_LAPI_URL without
// SWW_CROWDSEC_LAPI_KEY, the key without the URL, and a decision list
// checkCrowdSec refuses SWW_WAF_CROWDSEC_LAPI_URL without
// SWW_WAF_CROWDSEC_LAPI_KEY, the key without the URL, and a decision list
// that is fetched as another list too.
func (e *environment) checkCrowdSec(cfg *Config) {
decisionsURL := cfg.CrowdSecDecisionsURL
switch {
case decisionsURL != "" && cfg.CrowdSecKey == "":
e.check("SWW_CROWDSEC_LAPI_URL", fmt.Errorf(
"is set while SWW_CROWDSEC_LAPI_KEY is unset; %w", errNeedsLAPIKey))
e.check("SWW_WAF_CROWDSEC_LAPI_URL", fmt.Errorf(
"is set while SWW_WAF_CROWDSEC_LAPI_KEY is unset; %w", errNeedsLAPIKey))
case decisionsURL == "" && cfg.CrowdSecKey != "":
e.check("SWW_CROWDSEC_LAPI_KEY", fmt.Errorf(
"is set while SWW_CROWDSEC_LAPI_URL is unset; %w", errLAPIKeyUnused))
e.check("SWW_WAF_CROWDSEC_LAPI_KEY", fmt.Errorf(
"is set while SWW_WAF_CROWDSEC_LAPI_URL is unset; %w", errLAPIKeyUnused))
case decisionsURL != "" && (slices.Contains(cfg.BlocklistURLs, decisionsURL) ||
decisionsURL == cfg.ASNLimitPercentURL):
e.check("SWW_CROWDSEC_LAPI_URL", fmt.Errorf("gives the decision list %q, which %w",
decisionsURL, errAnotherList))
e.check("SWW_WAF_CROWDSEC_LAPI_URL",
fmt.Errorf("gives the decision list %q, which %w", decisionsURL, errAnotherList))
}
}
// checkTagLists refuses a list of SWW_TAG_LISTS whose URL is fetched as
// checkTagLists refuses a list of SWW_WAF_TAG_LISTS whose URL is fetched as
// another list too: the file at a URL is read as one kind of list.
func (e *environment) checkTagLists(cfg *Config) {
for _, list := range cfg.TagLists {
if slices.Contains(cfg.BlocklistURLs, list.URL) ||
list.URL == cfg.ASNLimitPercentURL || list.URL == cfg.CrowdSecDecisionsURL {
e.check("SWW_TAG_LISTS", fmt.Errorf("%q %w", list.URL, errFetchedOtherwise))
e.check("SWW_WAF_TAG_LISTS", fmt.Errorf("%q %w", list.URL, errFetchedOtherwise))
}
}
}
// checkTags refuses a tag of SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT that would
// checkTags refuses a tag of SWW_WAF_TAG_DENY or SWW_WAF_TAG_LIMIT_PERCENT that would
// never apply: crowdsec, whose request is refused before tags are acted on, and
// any tag these settings cannot give a request.
func (e *environment) checkTags(cfg *Config) {
for _, tag := range cfg.TagDeny {
e.check("SWW_TAG_DENY", checkTag(cfg, tag))
e.check("SWW_WAF_TAG_DENY", checkTag(cfg, tag))
}
for tag := range cfg.TagLimitPercent {
e.check("SWW_TAG_LIMIT_PERCENT", checkTag(cfg, tag))
e.check("SWW_WAF_TAG_LIMIT_PERCENT", checkTag(cfg, tag))
}
}
@@ -1216,13 +1232,13 @@ func requestCanGetTag(cfg *Config, tag string) bool {
})
}
// TagsNeedLookup reports whether SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT
// TagsNeedLookup reports whether SWW_WAF_TAG_DENY or SWW_WAF_TAG_LIMIT_PERCENT
// names an asn= or country= tag.
func (c *Config) TagsNeedLookup() bool {
return slices.ContainsFunc(c.TagDeny, isLookupTag) || tagLimitNeedsLookups(c)
}
// tagLimitNeedsLookups reports whether SWW_TAG_LIMIT_PERCENT names an asn=
// tagLimitNeedsLookups reports whether SWW_WAF_TAG_LIMIT_PERCENT names an asn=
// or country= tag.
func tagLimitNeedsLookups(cfg *Config) bool {
for tag := range cfg.TagLimitPercent {
@@ -1392,7 +1408,7 @@ func (e *environment) logRemoteURL(name string) *url.URL {
// unset. Its value names a file already, so, unlike the other settings,
// it has no _FILE form. Whoever uses the file reads it.
func (e *environment) filePath(name string) string {
path, _ := e.lookupEnv(name)
path, _ := readSetting(e.v, name)
e.settings = append(e.settings, slog.String(name, path))
return path
@@ -1402,7 +1418,7 @@ func (e *environment) filePath(name string) string {
// certificates. Unset or empty, it is nil. Its value names a file
// already, so, unlike the other settings, it has no _FILE form.
func (e *environment) certificates(name string) *x509.CertPool {
path, _ := e.lookupEnv(name)
path, _ := readSetting(e.v, name)
e.settings = append(e.settings, slog.String(name, path))
if path == "" {
@@ -1435,14 +1451,14 @@ func (e *environment) facility(name, defaultValue string) int {
return number
}
// instanceName reads SWW_INSTANCE_NAME, by default the host's name. It
// instanceName reads SWW_WAF_INSTANCE_NAME, by default the host's name. It
// must be valid UTF-8: the metrics library panics on a label that is not.
func (e *environment) instanceName() string {
hostname, _ := os.Hostname() // "" when the host has no name to give
value := e.value("SWW_INSTANCE_NAME", hostname)
value := e.value("SWW_WAF_INSTANCE_NAME", hostname)
if !utf8.ValidString(value) {
e.check("SWW_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8))
e.check("SWW_WAF_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8))
}
return value
@@ -1467,7 +1483,7 @@ func (e *environment) appName(
case set:
e.check(name, fmt.Errorf("%q %w", value, errNotAppName))
case sending:
e.check(name, fmt.Errorf("is unset, and SWW_INSTANCE_NAME %q, its default, %w",
e.check(name, fmt.Errorf("is unset, and SWW_WAF_INSTANCE_NAME %q, its default, %w",
value, errNotAppName))
}
@@ -1475,22 +1491,22 @@ func (e *environment) appName(
}
// checkInstanceNameForNtfy refuses an instance name that holds a control
// character while ntfySet, SWW_ALERT_NTFY_URL being set: ntfy is sent
// character while ntfySet, SWW_WAF_ALERT_NTFY_URL being set: ntfy is sent
// the instance name in a header, which cannot hold one.
func (e *environment) checkInstanceNameForNtfy(
instanceName string,
ntfySet bool,
) {
if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) {
e.check("SWW_INSTANCE_NAME", fmt.Errorf(
"%q %w, and is sent to ntfy in a header while SWW_ALERT_NTFY_URL is set",
e.check("SWW_WAF_INSTANCE_NAME", fmt.Errorf(
"%q %w, and is sent to ntfy in a header while SWW_WAF_ALERT_NTFY_URL is set",
instanceName, errControlCharacter))
}
}
// webhookURL reads a setting that is a URL each alert is posted to:
// SWW_ALERT_WEBHOOK_URL, SWW_ALERT_SLACK_WEBHOOK_URL or
// SWW_ALERT_NTFY_URL. Unset or empty, it is nil, and no alert is posted
// SWW_WAF_ALERT_WEBHOOK_URL, SWW_WAF_ALERT_SLACK_WEBHOOK_URL or
// SWW_WAF_ALERT_NTFY_URL. Unset or empty, it is nil, and no alert is posted
// there. The log shows ******** in place of its path and query, and an
// error shows none of it, since a webhook or an ntfy topic can carry its
// secret there.
@@ -1942,7 +1958,7 @@ func parseCountry(value string) (string, error) {
// ParseASN reads an AS number such as AS64496, in either case, and
// returns it as the lookup gives it: AS and the number, in capitals and
// without leading zeros. The file SWW_ASN_LIMIT_PERCENT_URL names is
// without leading zeros. The file SWW_WAF_ASN_LIMIT_PERCENT_URL names is
// read with it too.
func ParseASN(value string) (string, error) {
digits, hasAS := strings.CutPrefix(strings.ToUpper(value), "AS")
@@ -1969,7 +1985,7 @@ const (
ExploitSignatureTag = "exploitsignature"
)
// TagList is an address list of SWW_TAG_LISTS: the tag it adds to the
// TagList is an address list of SWW_WAF_TAG_LISTS: the tag it adds to the
// requests of the clients it holds, and the URL it is fetched from.
type TagList struct {
Name string
@@ -2162,7 +2178,7 @@ func parsePercents(
}
// ParsePercent reads a percentage, a whole number from 0 to 100. The file
// SWW_ASN_LIMIT_PERCENT_URL names is read with it too.
// SWW_WAF_ASN_LIMIT_PERCENT_URL names is read with it too.
func ParsePercent(value string) (int64, error) {
percent, err := strconv.ParseInt(value, 10, 64)
if err != nil || percent < 0 || percent > 100 {
@@ -2393,7 +2409,7 @@ const (
maxLabelLength = 63
// labelChars are the characters a label of a DNS zone may hold.
labelChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-"
// dnsPort is the port a resolver is asked on when SWW_DNSBL_RESOLVER
// dnsPort is the port a resolver is asked on when SWW_WAF_DNSBL_RESOLVER
// gives none.
dnsPort = 53
)
+171 -155
View File
@@ -17,106 +17,110 @@ import (
"testing"
"time"
"github.com/spf13/viper"
"sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
)
// The settings, by name.
const (
listenAddr = "SWW_LISTEN_ADDR"
mode = "SWW_MODE"
ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX"
maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS"
allowNets = "SWW_ALLOW_NETS"
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
denyNets = "SWW_DENY_NETS"
rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE"
rateLimitPerHour = "SWW_RATE_LIMIT_PER_HOUR"
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS"
bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
bytesCount = "SWW_BYTES_COUNT"
lookupSource = "SWW_LOOKUP_SOURCE"
lookupDBPath = "SWW_LOOKUP_DB_PATH"
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
tagLists = "SWW_TAG_LISTS"
tagDeny = "SWW_TAG_DENY"
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL"
blocklistURLs = "SWW_BLOCKLIST_URLS"
blocklistRefresh = "SWW_BLOCKLIST_REFRESH"
blocklistAction = "SWW_BLOCKLIST_ACTION"
dnsblZones = "SWW_DNSBL_ZONES"
dnsblResolver = "SWW_DNSBL_RESOLVER"
abuseIPDBKey = "SWW_ABUSEIPDB_KEY"
abuseIPDBMinScore = "SWW_ABUSEIPDB_MIN_SCORE"
abuseIPDBDailyBudget = "SWW_ABUSEIPDB_DAILY_BUDGET"
reputationAction = "SWW_REPUTATION_ACTION"
reputationCacheTTL = "SWW_REPUTATION_CACHE_TTL"
reputationTimeout = "SWW_REPUTATION_TIMEOUT"
routeWatchURL = "SWW_ROUTEWATCH_URL"
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
banResponse = "SWW_BAN_RESPONSE"
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW"
maxBanDuration = "SWW_MAX_BAN_DURATION"
attackBanDuration = "SWW_ATTACK_BAN_DURATION"
maxBans = "SWW_MAX_BANS"
banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX"
stateDir = "SWW_STATE_DIR"
stateWriteDelay = "SWW_STATE_WRITE_DELAY"
stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL"
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
metricsTopN = "SWW_METRICS_TOP_N"
instanceName = "SWW_INSTANCE_NAME"
logRequestHeaders = "SWW_LOG_REQUEST_HEADERS"
logLevel = "SWW_LOG_LEVEL"
rulesDir = "SWW_RULES_DIR"
rulesEnabled = "SWW_RULES_ENABLED"
wafMode = "SWW_WAF_MODE"
wafParanoiaLevel = "SWW_WAF_PARANOIA_LEVEL"
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
trapPaths = "SWW_TRAP_PATHS"
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
logRemoteURL = "SWW_LOG_REMOTE_URL"
logRemoteTLSCAFile = "SWW_LOG_REMOTE_TLS_CA_FILE"
logRemoteBuffer = "SWW_LOG_REMOTE_BUFFER"
logRemoteFacility = "SWW_LOG_REMOTE_FACILITY"
logRemoteAppName = "SWW_LOG_REMOTE_APP_NAME"
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
alertWebhookHeaders = "SWW_ALERT_WEBHOOK_HEADERS"
alertSlackWebhookURL = "SWW_ALERT_SLACK_WEBHOOK_URL"
alertNtfyURL = "SWW_ALERT_NTFY_URL"
alertNtfyToken = "SWW_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
alertEvents = "SWW_ALERT_EVENTS"
alertCooldown = "SWW_ALERT_COOLDOWN"
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
watchNets = "SWW_WATCH_NETS"
listenAddr = "SWW_WAF_LISTEN_ADDR"
mode = "SWW_WAF_MODE"
ipv6GroupPrefix = "SWW_WAF_IPV6_GROUP_PREFIX"
maxTrackedClients = "SWW_WAF_MAX_TRACKED_CLIENTS"
allowNets = "SWW_WAF_ALLOW_NETS"
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
denyNets = "SWW_WAF_DENY_NETS"
rateLimitPerMinute = "SWW_WAF_RATE_LIMIT_PER_MINUTE"
rateLimitPerHour = "SWW_WAF_RATE_LIMIT_PER_HOUR"
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
rateLimitExemptPaths = "SWW_WAF_RATE_LIMIT_EXEMPT_PATHS"
bytesLimitPerMinute = "SWW_WAF_BYTES_LIMIT_PER_MINUTE"
bytesLimitPerHour = "SWW_WAF_BYTES_LIMIT_PER_HOUR"
bytesLimitPerDay = "SWW_WAF_BYTES_LIMIT_PER_DAY"
bytesCount = "SWW_WAF_BYTES_COUNT"
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
lookupTimeout = "SWW_WAF_LOOKUP_TIMEOUT"
addLookupHeaders = "SWW_WAF_ADD_LOOKUP_HEADERS"
allowedCountries = "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
tagLists = "SWW_WAF_TAG_LISTS"
tagDeny = "SWW_WAF_TAG_DENY"
tagLimitPercent = "SWW_WAF_TAG_LIMIT_PERCENT"
asnBytesPercent = "SWW_WAF_ASN_BYTES_PERCENT"
countryBytesPercent = "SWW_WAF_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWW_WAF_UNKNOWN_LIMIT_PERCENT"
asnLimitPercentURL = "SWW_WAF_ASN_LIMIT_PERCENT_URL"
blocklistURLs = "SWW_WAF_BLOCKLIST_URLS"
blocklistRefresh = "SWW_WAF_BLOCKLIST_REFRESH"
blocklistAction = "SWW_WAF_BLOCKLIST_ACTION"
dnsblZones = "SWW_WAF_DNSBL_ZONES"
dnsblResolver = "SWW_WAF_DNSBL_RESOLVER"
abuseIPDBKey = "SWW_WAF_ABUSEIPDB_KEY"
abuseIPDBMinScore = "SWW_WAF_ABUSEIPDB_MIN_SCORE"
abuseIPDBDailyBudget = "SWW_WAF_ABUSEIPDB_DAILY_BUDGET"
reputationAction = "SWW_WAF_REPUTATION_ACTION"
reputationCacheTTL = "SWW_WAF_REPUTATION_CACHE_TTL"
reputationTimeout = "SWW_WAF_REPUTATION_TIMEOUT"
routeWatchURL = "SWW_WAF_ROUTEWATCH_URL"
crowdSecURL = "SWW_WAF_CROWDSEC_LAPI_URL"
crowdSecKey = "SWW_WAF_CROWDSEC_LAPI_KEY"
banResponse = "SWW_WAF_BAN_RESPONSE"
limitBanDuration = "SWW_WAF_LIMIT_BAN_DURATION"
limitBanRepeatWindow = "SWW_WAF_LIMIT_BAN_REPEAT_WINDOW"
maxBanDuration = "SWW_WAF_MAX_BAN_DURATION"
attackBanDuration = "SWW_WAF_ATTACK_BAN_DURATION"
maxBans = "SWW_WAF_MAX_BANS"
banScopeV4Prefix = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
stateDir = "SWW_WAF_STATE_DIR"
stateWriteDelay = "SWW_WAF_STATE_WRITE_DELAY"
stateCounterInterval = "SWW_WAF_STATE_COUNTER_INTERVAL"
cacheDir = "SWW_WAF_CACHE_DIR"
adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
metricsTopN = "SWW_WAF_METRICS_TOP_N"
instanceName = "SWW_WAF_INSTANCE_NAME"
logRequestHeaders = "SWW_WAF_LOG_REQUEST_HEADERS"
logLevel = "SWW_WAF_LOG_LEVEL"
rulesDir = "SWW_WAF_RULES_DIR"
rulesEnabled = "SWW_WAF_RULES_ENABLED"
wafMode = "SWW_WAF_CORE_RULE_SET_MODE"
wafParanoiaLevel = "SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL"
wafAnomalyThreshold = "SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD"
wafDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES"
wafExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS"
wafBodyLimit = "SWW_WAF_CORE_RULE_SET_BODY_LIMIT"
exclusionsFile = "SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE"
trapPaths = "SWW_WAF_TRAP_PATHS"
errorBurstThreshold = "SWW_WAF_ERROR_BURST_THRESHOLD"
logRemoteURL = "SWW_WAF_LOG_REMOTE_URL"
logRemoteTLSCAFile = "SWW_WAF_LOG_REMOTE_TLS_CA_FILE"
logRemoteBuffer = "SWW_WAF_LOG_REMOTE_BUFFER"
logRemoteFacility = "SWW_WAF_LOG_REMOTE_FACILITY"
logRemoteAppName = "SWW_WAF_LOG_REMOTE_APP_NAME"
alertWebhookURL = "SWW_WAF_ALERT_WEBHOOK_URL"
alertWebhookHeaders = "SWW_WAF_ALERT_WEBHOOK_HEADERS"
alertSlackWebhookURL = "SWW_WAF_ALERT_SLACK_WEBHOOK_URL"
alertNtfyURL = "SWW_WAF_ALERT_NTFY_URL"
alertNtfyToken = "SWW_WAF_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
alertEvents = "SWW_WAF_ALERT_EVENTS"
alertCooldown = "SWW_WAF_ALERT_COOLDOWN"
alertMaxPerHour = "SWW_WAF_ALERT_MAX_PER_HOUR"
anomalyNetV4Prefix = "SWW_WAF_ANOMALY_NET_V4_PREFIX"
anomalyNetV6Prefix = "SWW_WAF_ANOMALY_NET_V6_PREFIX"
watchNets = "SWW_WAF_WATCH_NETS"
)
// The anomaly thresholds: each of the prefixes below, which name a scope,
// followed by each of the four ends.
const (
anomalyClient = "SWW_ANOMALY_CLIENT_"
anomalyNet = "SWW_ANOMALY_NET_"
anomalyASN = "SWW_ANOMALY_ASN_"
anomalyTotal = "SWW_ANOMALY_TOTAL_"
watch = "SWW_WATCH_"
anomalyClient = "SWW_WAF_ANOMALY_CLIENT_"
anomalyNet = "SWW_WAF_ANOMALY_NET_"
anomalyASN = "SWW_WAF_ANOMALY_ASN_"
anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_"
watch = "SWW_WAF_WATCH_"
requestsPerMinute = "REQUESTS_PER_MINUTE"
requestsPerHour = "REQUESTS_PER_HOUR"
@@ -157,21 +161,21 @@ func loggedAnomalyDefaults() map[string]string {
return logged
}
// defaultAlertEvents is the default of SWW_ALERT_EVENTS, and
// defaultAlertCooldown that of SWW_ALERT_COOLDOWN.
// defaultAlertEvents is the default of SWW_WAF_ALERT_EVENTS, and
// defaultAlertCooldown that of SWW_WAF_ALERT_COOLDOWN.
const (
defaultAlertEvents = "ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit," +
"source_failure,file_error"
defaultAlertCooldown = "15m"
)
// defaultReputationCacheTTL is the default of SWW_REPUTATION_CACHE_TTL.
// defaultReputationCacheTTL is the default of SWW_WAF_REPUTATION_CACHE_TTL.
const defaultReputationCacheTTL = "24h"
// defaultWAFDisabledRules is the default of SWW_WAF_DISABLED_RULES.
// defaultWAFDisabledRules is the default of SWW_WAF_CORE_RULE_SET_DISABLED_RULES.
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
// defaultLogRequestHeaders is the default of SWW_LOG_REQUEST_HEADERS.
// defaultLogRequestHeaders is the default of SWW_WAF_LOG_REQUEST_HEADERS.
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
"content-type,origin,range"
@@ -196,8 +200,8 @@ const (
otherToken = "fedcba9876543210fedcba9876543210"
)
// instance is an SWW_INSTANCE_NAME that is a valid app name too, and
// remoteURL an SWW_LOG_REMOTE_URL, for the tests that send the lines.
// instance is an SWW_WAF_INSTANCE_NAME that is a valid app name too, and
// remoteURL an SWW_WAF_LOG_REMOTE_URL, for the tests that send the lines.
const (
instance = "fsn1app1/blog"
remoteURL = "syslog+udp://192.0.2.1:514"
@@ -212,7 +216,7 @@ const enabled = "true"
// disabled is false, as a setting's value.
const disabled = "false"
// defaultLookupSource is the default of SWW_LOOKUP_SOURCE, and
// defaultLookupSource is the default of SWW_WAF_LOOKUP_SOURCE, and
// fileSource the source that is the lookup database.
const (
defaultLookupSource = "geojs"
@@ -222,18 +226,16 @@ const (
// environment is a set of environment variables, for FromEnvironment.
type environment map[string]string
// lookupEnv reads one of the variables, as os.LookupEnv does.
func (e environment) lookupEnv(name string) (string, bool) {
value, ok := e[name]
return value, ok
// viper returns a viper holding each of the variables.
func (e environment) viper() *viper.Viper {
return configtest.Viper(e)
}
// fromEnvironment reads the settings from env, which must be valid.
func fromEnvironment(t *testing.T, env environment) *config.Config {
t.Helper()
cfg, err := config.FromEnvironment(env.lookupEnv)
cfg, err := config.FromEnvironment(env.viper())
if err != nil {
t.Fatalf("settings %v: %v", env, err)
}
@@ -262,6 +264,7 @@ func TestDefaults(t *testing.T) {
StateDir: "/var/lib/sww",
StateWriteDelay: 10 * time.Second,
StateCounterInterval: 15 * time.Minute,
CacheDir: "/var/cache/sww",
MetricsToken: "",
MetricsTopN: 50,
RulesDir: "/etc/sww/rules.d",
@@ -346,6 +349,7 @@ func TestValuesAsSet(t *testing.T) {
stateDir: "/srv/waf-state",
stateWriteDelay: "500ms",
stateCounterInterval: "1h",
cacheDir: "/srv/waf-cache",
metricsToken: token,
metricsTopN: "10",
rulesDir: "/srv/waf-rules",
@@ -368,6 +372,7 @@ func TestValuesAsSet(t *testing.T) {
StateDir: "/srv/waf-state",
StateWriteDelay: 500 * time.Millisecond,
StateCounterInterval: time.Hour,
CacheDir: "/srv/waf-cache",
MetricsToken: token,
MetricsTopN: 10,
RulesDir: "/srv/waf-rules",
@@ -462,7 +467,7 @@ func TestPathPrefixNotStartingWithSlashStopsTheStart(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(
environment{rateLimitExemptPaths: "/favicon.ico,assets/"}.lookupEnv)
environment{rateLimitExemptPaths: "/favicon.ico,assets/"}.viper())
want := rateLimitExemptPaths + `: "assets/" is not a path prefix ` +
`starting with /, such as /assets/`
@@ -517,7 +522,7 @@ func TestInvalidTrapPathOrErrorBurstThresholdStopsTheStart(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want
if err == nil || err.Error() != want {
@@ -646,7 +651,7 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want
if err == nil || err.Error() != want {
@@ -771,7 +776,7 @@ func TestInvalidRemoteLogSettingStopsTheStart(t *testing.T) {
{logRemoteAppName, ""}, {logRemoteAppName, "my app"},
{logRemoteAppName, "gitéa"}, {logRemoteAppName, strings.Repeat("a", 49)},
} {
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") {
t.Errorf("%s=%q: error %v, want one naming it", tc.name, tc.value, err)
}
@@ -788,7 +793,7 @@ func TestRemoteLogCAFileWithoutCertificateStopsTheStart(t *testing.T) {
t.Fatalf("write %s: %v", caFile, err)
}
_, err = config.FromEnvironment(environment{logRemoteTLSCAFile: caFile}.lookupEnv)
_, err = config.FromEnvironment(environment{logRemoteTLSCAFile: caFile}.viper())
want := logRemoteTLSCAFile + `: "` + caFile + `" holds no PEM certificate`
if err == nil || err.Error() != want {
@@ -803,7 +808,7 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(t *testing.T) {
sending := environment{logRemoteURL: remoteURL, instanceName: spaced}
_, err := config.FromEnvironment(sending.lookupEnv)
_, err := config.FromEnvironment(sending.viper())
want := logRemoteAppName + `: is unset, and ` + instanceName +
` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` +
@@ -833,7 +838,7 @@ func TestAppNameSetStopsTheStartWhileSending(t *testing.T) {
logRemoteURL: remoteURL,
instanceName: instance,
logRemoteAppName: "my app",
}.lookupEnv)
}.viper())
want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
`characters without a space, such as blog`
@@ -964,7 +969,7 @@ func TestWebhookHeadersAreLoggedMaskedAndNeverShown(t *testing.T) {
// An item that is not a header is named by its place, not shown.
_, err := config.FromEnvironment(environment{
alertWebhookHeaders: "X-Team:ops," + secret,
}.lookupEnv)
}.viper())
want := alertWebhookHeaders + ": item 2 is not a header name followed by : " +
"and the header's value, such as Authorization:Bearer <token>"
@@ -1001,7 +1006,7 @@ func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) {
"ftp://hooks.example/services/" + secret,
"https://hooks.example/services/%zz" + secret,
} {
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv)
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.viper())
want := alertWebhookURL + ": is not an http or https URL without a user or " +
"a fragment, such as https://alerts.example/sww"
@@ -1052,7 +1057,7 @@ func TestNtfyTokenWithAControlCharacterStopsTheStartWithoutShowingIt(t *testing.
"set": {alertNtfyToken: token + "\r"},
"in a file": {alertNtfyToken + "_FILE": writeFile(t, token+"\r\n")},
} {
_, err := config.FromEnvironment(env.lookupEnv)
_, err := config.FromEnvironment(env.viper())
want := alertNtfyToken + ": holds a control character, such as the " +
"carriage return of a Windows line end"
@@ -1069,7 +1074,7 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testin
_, err := config.FromEnvironment(environment{
instanceName: name, alertNtfyURL: "https://ntfy.example/sww-alerts",
}.lookupEnv)
}.viper())
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
`carriage return of a Windows line end, and is sent to ntfy in a header ` +
@@ -1094,7 +1099,7 @@ func TestInstanceNameNotUTF8StopsTheStart(t *testing.T) {
"set": {instanceName: latin1},
"in a file": {instanceName + "_FILE": writeFile(t, latin1+"\n")},
} {
_, err := config.FromEnvironment(env.lookupEnv)
_, err := config.FromEnvironment(env.viper())
want := instanceName + `: "caf\xe9" is not valid UTF-8`
if err == nil || err.Error() != want {
@@ -1109,9 +1114,10 @@ func TestCodeInAllowedCountriesAndTagDenyStopsTheStart(t *testing.T) {
_, err := config.FromEnvironment(environment{
tagDeny: "country=cn,country=ru",
allowedCountries: "de,RU",
}.lookupEnv)
}.viper())
if err == nil {
t.Fatal("ru in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY was accepted")
t.Fatal("ru in SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES and " +
"SWW_WAF_TAG_DENY was accepted")
}
if !strings.HasPrefix(err.Error(), allowedCountries+": ") ||
@@ -1171,7 +1177,7 @@ func TestLookupDBPathGoesWithTheFileSourceAlone(t *testing.T) {
lookupDBPath + ": is set while " + lookupSource + " is off; only file reads it",
},
} {
_, err := config.FromEnvironment(tc.env.lookupEnv)
_, err := config.FromEnvironment(tc.env.viper())
if err == nil || err.Error() != tc.want {
t.Errorf("settings %v: error %v, want %s", tc.env, err, tc.want)
}
@@ -1200,7 +1206,7 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
_, err := config.FromEnvironment(environment{
lookupSource: off, name: value,
}.lookupEnv)
}.viper())
if err == nil || !strings.HasPrefix(err.Error(), name+": ") ||
!strings.Contains(err.Error(), lookupSource+" is off") {
t.Errorf("error %v, want one naming %s and %s", err, name, lookupSource)
@@ -1209,7 +1215,7 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
}
// Set empty, the lists need nothing looked up, and nor does
// SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, a tag the
// SWW_WAF_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, a tag the
// lookup does not give, an anomaly threshold per AS number that is off,
// any other anomaly threshold, or a blocklist.
env := environment{
@@ -1351,7 +1357,7 @@ func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want
if err == nil || err.Error() != want {
@@ -1445,14 +1451,14 @@ func TestRouteWatchURLAndPrefixTags(t *testing.T) {
{environment{lookupSource: off, routeWatchURL: rw, tagDeny: "country=DE"}, tagDeny},
{environment{lookupSource: off, tagDeny: "asn=64496"}, tagDeny},
} {
_, err := config.FromEnvironment(stopped.env.lookupEnv)
_, err := config.FromEnvironment(stopped.env.viper())
if err == nil || !strings.HasPrefix(err.Error(), stopped.name+": ") {
t.Errorf("%v gave the error %v, want one naming %s", stopped.env, err, stopped.name)
}
}
badURL := environment{routeWatchURL: "ftp://routewatch.example"}
_, err := config.FromEnvironment(badURL.lookupEnv)
_, err := config.FromEnvironment(badURL.viper())
want := routeWatchURL + `: "ftp://routewatch.example" is not an http or https URL ` +
"without a user or a fragment, such as https://routewatch.example"
@@ -1507,7 +1513,7 @@ func TestTagThatNoRequestCanGetIsToldApartFromOneItCan(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(tc.env.lookupEnv)
_, err := config.FromEnvironment(tc.env.viper())
switch {
case tc.want == "" && err != nil:
@@ -1545,7 +1551,7 @@ func TestErrorOfATagSettingLeavesOutAZoneKey(t *testing.T) {
{tagLimitPercent, tag + ":50," + tag + " :25", twice},
{tagLimitPercent, tag + ":50,,", emptyItem},
} {
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want
if err == nil || err.Error() != want {
@@ -1567,7 +1573,7 @@ func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) {
{"the file of AS:percent lines", asnURL, environment{asnLimitPercentURL: asnURL}},
{
"the CrowdSec decision list", lapi + "/v1/decisions",
environment{"SWW_CROWDSEC_LAPI_URL": lapi, "SWW_CROWDSEC_LAPI_KEY": "key"},
environment{"SWW_WAF_CROWDSEC_LAPI_URL": lapi, "SWW_WAF_CROWDSEC_LAPI_KEY": "key"},
},
} {
t.Run(tc.name, func(t *testing.T) {
@@ -1575,10 +1581,10 @@ func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) {
tc.env[tagLists] = "dup=" + tc.listURL
want := tagLists + `: "` + tc.listURL + `" is in SWW_BLOCKLIST_URLS, is ` +
"SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too"
want := tagLists + `: "` + tc.listURL + `" is in SWW_WAF_BLOCKLIST_URLS, is ` +
"SWW_WAF_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too"
_, err := config.FromEnvironment(tc.env.lookupEnv)
_, err := config.FromEnvironment(tc.env.viper())
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
@@ -1647,7 +1653,7 @@ func TestInvalidBiasedThresholdStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want
if err == nil || err.Error() != want {
@@ -1665,7 +1671,7 @@ const (
asnURL = "https://lists.example/asn.txt"
)
// The actions of SWW_BLOCKLIST_ACTION, as Config gives them.
// The actions of SWW_WAF_BLOCKLIST_ACTION, as Config gives them.
const (
actionDeny = "deny"
actionLimit = "limit"
@@ -1744,7 +1750,7 @@ func TestInvalidReputationSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want
if err == nil || err.Error() != want {
@@ -1759,9 +1765,9 @@ func TestASNLimitPercentURLThatIsABlocklistStopsTheStart(t *testing.T) {
_, err := config.FromEnvironment(environment{
blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL,
}.lookupEnv)
}.viper())
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_BLOCKLIST_URLS too`
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_WAF_BLOCKLIST_URLS too`
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
@@ -1886,7 +1892,7 @@ func TestInvalidDNSBLSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want
if err == nil || err.Error() != want {
@@ -1954,7 +1960,7 @@ func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) {
{spamhaus + "," + otherZone, `"` + spamhausMasked + `" is listed twice`},
{spamhaus + ",,", "has an empty item in its list"},
} {
_, err := config.FromEnvironment(environment{dnsblZones: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{dnsblZones: tc.value}.viper())
want := dnsblZones + ": " + tc.want
if err == nil || err.Error() != want {
@@ -2007,7 +2013,7 @@ func TestInvalidAbuseIPDBSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
want := tc.name + ": " + tc.want
if err == nil || err.Error() != want {
@@ -2064,7 +2070,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
notLAPIURL = " is not an http or https URL without a user or a fragment, " +
"such as http://172.17.0.1:8080"
anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` +
`SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too`
`SWW_WAF_BLOCKLIST_URLS or is SWW_WAF_ASN_LIMIT_PERCENT_URL too`
)
for _, tc := range []struct {
@@ -2121,7 +2127,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(tc.env.lookupEnv)
_, err := config.FromEnvironment(tc.env.viper())
if err == nil || err.Error() != tc.want {
t.Errorf("error %v, want %s", err, tc.want)
}
@@ -2274,14 +2280,14 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
// wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff checks that with rule
// files off, and either setting turning off the Core Rule Set, the tag
// exploitsignature in SWW_TAG_DENY stops the start.
// exploitsignature in SWW_WAF_TAG_DENY stops the start.
func wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff(t *testing.T) {
t.Helper()
for _, name := range []string{wafMode, wafAnomalyThreshold} {
_, err := config.FromEnvironment(environment{
tagDeny: exploitTag, rulesEnabled: disabled, name: off,
}.lookupEnv)
}.viper())
if err == nil || !strings.HasPrefix(err.Error(), tagDeny+": ") {
t.Errorf("%s=off with rule files off gave the error %v, want one naming %s",
name, err, tagDeny)
@@ -2302,6 +2308,7 @@ func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/sww"},
{stateWriteDelay, off}, {stateWriteDelay, "0s"},
{stateCounterInterval, off}, {stateCounterInterval, "15"},
{cacheDir, ""}, {cacheDir, "cache"}, {cacheDir, "./var/cache/sww"},
})
}
@@ -2314,7 +2321,7 @@ func wantStartStopped(t *testing.T, invalid []struct{ name, value string }) {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
_, err := config.FromEnvironment(environment{tc.name: tc.value}.viper())
if err == nil {
t.Fatalf("%s=%q was accepted", tc.name, tc.value)
}
@@ -2339,7 +2346,7 @@ func TestHostOrTransferEncodingStopsTheStart(t *testing.T) {
t.Run(value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{logRequestHeaders: value}.lookupEnv)
_, err := config.FromEnvironment(environment{logRequestHeaders: value}.viper())
if err == nil || err.Error() != logRequestHeaders+": "+want {
t.Errorf("error %v, want %s: %s", err, logRequestHeaders, want)
}
@@ -2356,7 +2363,7 @@ func TestShortTokenStopsTheStartWithoutShowingIt(t *testing.T) {
t.Run(name+"="+value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{name: value}.lookupEnv)
_, err := config.FromEnvironment(environment{name: value}.viper())
want := name + ": is shorter than 32 characters"
if err == nil || err.Error() != want {
@@ -2412,7 +2419,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
_, err := config.FromEnvironment(environment{
bytesLimitPerMinute + "_FILE": writeFile(t, "lots\n"),
}.lookupEnv)
}.viper())
want := bytesLimitPerMinute + `: "lots" is not a size such as 512K, 100M or 5G, or off`
if err == nil || err.Error() != want {
@@ -2423,7 +2430,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
logRemoteURL: remoteURL,
instanceName: instance,
logRemoteAppName + "_FILE": writeFile(t, "my app\n"),
}.lookupEnv)
}.viper())
want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
`characters without a space, such as blog`
@@ -2438,7 +2445,7 @@ func TestSettingAndItsFileBothSetStopsTheStart(t *testing.T) {
_, err := config.FromEnvironment(environment{
metricsToken: token,
metricsToken + "_FILE": writeFile(t, token),
}.lookupEnv)
}.viper())
want := metricsToken + ": is set, and so is " + metricsToken +
"_FILE; set only one of them"
@@ -2453,7 +2460,7 @@ func TestUnreadableSettingFileStopsTheStart(t *testing.T) {
dir := t.TempDir()
for _, path := range []string{filepath.Join(dir, "missing"), dir} {
_, err := config.FromEnvironment(environment{metricsToken + "_FILE": path}.lookupEnv)
_, err := config.FromEnvironment(environment{metricsToken + "_FILE": path}.viper())
want := metricsToken + "_FILE: cannot be read: "
if err == nil || !strings.HasPrefix(err.Error(), want) {
@@ -2574,6 +2581,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
stateDir: "/var/lib/sww",
stateWriteDelay: "10s",
stateCounterInterval: "15m",
cacheDir: "/var/cache/sww",
adminToken: "",
metricsToken: "",
metricsTopN: "50",
@@ -2674,8 +2682,8 @@ func wantLookupSettings(t *testing.T, got *config.Config, want config.Config) {
}
}
// wantBanSettings checks the settings for bans, the state files, the
// metrics and the rule files.
// wantBanSettings checks the settings for bans, the state and cache
// files, the metrics and the rule files.
func wantBanSettings(t *testing.T, got *config.Config, want config.Config) {
t.Helper()
@@ -2694,11 +2702,7 @@ func wantBanSettings(t *testing.T, got *config.Config, want config.Config) {
got.RulesDir, got.RulesEnabled, want.RulesDir, want.RulesEnabled)
}
if got.StateDir != want.StateDir ||
got.StateWriteDelay != want.StateWriteDelay ||
got.StateCounterInterval != want.StateCounterInterval {
t.Errorf("state settings\n%+v\nwant\n%+v", got, want)
}
wantStateSettings(t, got, want)
if got.MetricsToken != want.MetricsToken || got.MetricsTopN != want.MetricsTopN {
t.Errorf("metrics token %q and top %d, want %q and %d",
@@ -2706,6 +2710,18 @@ func wantBanSettings(t *testing.T, got *config.Config, want config.Config) {
}
}
// wantStateSettings checks the settings for the state and cache files.
func wantStateSettings(t *testing.T, got *config.Config, want config.Config) {
t.Helper()
if got.StateDir != want.StateDir ||
got.StateWriteDelay != want.StateWriteDelay ||
got.StateCounterInterval != want.StateCounterInterval ||
got.CacheDir != want.CacheDir {
t.Errorf("state settings\n%+v\nwant\n%+v", got, want)
}
}
// wantNetblocks checks a list of netblocks.
func wantNetblocks(t *testing.T, got []netip.Prefix, want ...string) {
t.Helper()
+16
View File
@@ -0,0 +1,16 @@
// Package configtest gives the tests of the packages that read settings the
// settings they choose.
package configtest
import "github.com/spf13/viper"
// Viper returns a viper holding each of settings, as the viper config.NewViper
// returns holds a process's environment.
func Viper(settings map[string]string) *viper.Viper {
v := viper.New()
for name, value := range settings {
v.Set(name, value)
}
return v
}
+37 -48
View File
@@ -8,38 +8,39 @@ import (
"net/netip"
"net/url"
"strconv"
"strings"
"time"
"github.com/spf13/viper"
)
// RProxy is the settings of `sww rproxy`. A size or time limit of zero
// is off.
type RProxy struct {
// ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR).
// ListenAddr is where rproxy listens (SWW_RPROXY_LISTEN_ADDR).
ListenAddr string
// UpstreamURL is the app (SWRP_UPSTREAM_URL).
// UpstreamURL is the app (SWW_RPROXY_UPSTREAM_URL).
UpstreamURL *url.URL
// TrustedProxies are the netblocks whose X-Forwarded-For is believed
// (SWRP_TRUSTED_PROXIES).
// (SWW_RPROXY_TRUSTED_PROXIES).
TrustedProxies []netip.Prefix
// WAFBackend is waf's URL (SWRP_WAF_BACKEND).
// WAFBackend is waf's URL (SWW_RPROXY_WAF_BACKEND).
WAFBackend *url.URL
// Timeout is how long rproxy waits for waf's answer to a question,
// and to a report (SWRP_TIMEOUT_MS). It cannot be off.
// and to a report (SWW_RPROXY_TIMEOUT_MS). It cannot be off.
Timeout time.Duration
// DefaultDisposition is what is done with a request when waf cannot be
// connected to, does not answer at all, or does not answer within Timeout:
// allow or deny (SWRP_DEFAULT_DISPOSITION).
// allow or deny (SWW_RPROXY_DEFAULT_DISPOSITION).
DefaultDisposition string
// MaxRequestSize is the largest request body (SWRP_MAX_REQUEST_SIZE), and
// MaxResponseSize the largest response body (SWRP_MAX_RESPONSE_SIZE).
// MaxRequestSize is the largest request body (SWW_RPROXY_MAX_REQUEST_SIZE), and
// MaxResponseSize the largest response body (SWW_RPROXY_MAX_RESPONSE_SIZE).
MaxRequestSize int64
MaxResponseSize int64
// MaxRequestTime bounds sending the whole request: the visitor's request
// line and headers, and then, from their end, its body on its way to the
// app (SWRP_MAX_REQUEST_TIME_MS). MaxResponseTime bounds the answer, from
// app (SWW_RPROXY_MAX_REQUEST_TIME_MS). MaxResponseTime bounds the answer, from
// the end of the request to its last byte reaching the visitor
// (SWRP_MAX_RESPONSE_TIME_MS).
// (SWW_RPROXY_MAX_RESPONSE_TIME_MS).
MaxRequestTime time.Duration
MaxResponseTime time.Duration
@@ -58,24 +59,26 @@ var (
"is not a whole number of milliseconds above zero, such as 60000, or off")
)
// RProxyFromEnvironment reads rproxy's settings with lookupEnv, normally
// os.LookupEnv. A setting that is not set takes its default, and
// SWRP_WAF_BACKEND, which has none, must be set. A setting that is set but
// invalid is an error that names it. Since rproxy never touches disk, its
// settings have no _FILE form.
func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, error) {
env := &environment{lookupEnv: withoutFileForms(lookupEnv)}
// RProxyFromEnvironment reads rproxy's settings through v, normally the viper
// NewViper returns, bound to the process's environment. A setting that is not
// set takes its default, and SWW_RPROXY_WAF_BACKEND, which has none, must be
// set. A setting may instead be given as a file: the variable named by the
// setting's name with _FILE added names the file, which is read now. A setting
// that is set but invalid is an error that names it.
func RProxyFromEnvironment(v *viper.Viper) (*RProxy, error) {
env := &environment{v: v}
cfg := &RProxy{
ListenAddr: env.address("SWRP_LISTEN_ADDR"),
UpstreamURL: env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL),
TrustedProxies: env.netblocks("SWRP_TRUSTED_PROXIES", privateRanges),
WAFBackend: env.wafBackend("SWRP_WAF_BACKEND"),
Timeout: env.milliseconds("SWRP_TIMEOUT_MS", "2000"),
DefaultDisposition: env.disposition("SWRP_DEFAULT_DISPOSITION", "allow"),
MaxRequestSize: env.size("SWRP_MAX_REQUEST_SIZE", "100M"),
MaxResponseSize: env.size("SWRP_MAX_RESPONSE_SIZE", "1G"),
MaxRequestTime: env.millisecondsOrOff("SWRP_MAX_REQUEST_TIME_MS", "60000"),
MaxResponseTime: env.millisecondsOrOff("SWRP_MAX_RESPONSE_TIME_MS", "1800000"),
ListenAddr: env.address("SWW_RPROXY_LISTEN_ADDR"),
UpstreamURL: env.appURL("SWW_RPROXY_UPSTREAM_URL", defaultUpstreamURL),
TrustedProxies: env.netblocks("SWW_RPROXY_TRUSTED_PROXIES", privateRanges),
WAFBackend: env.wafBackend("SWW_RPROXY_WAF_BACKEND"),
Timeout: env.milliseconds("SWW_RPROXY_TIMEOUT_MS", "2000"),
DefaultDisposition: env.disposition("SWW_RPROXY_DEFAULT_DISPOSITION", "allow"),
MaxRequestSize: env.size("SWW_RPROXY_MAX_REQUEST_SIZE", "100M"),
MaxResponseSize: env.size("SWW_RPROXY_MAX_RESPONSE_SIZE", "1G"),
MaxRequestTime: env.millisecondsOrOff("SWW_RPROXY_MAX_REQUEST_TIME_MS", "60000"),
MaxResponseTime: env.millisecondsOrOff(
"SWW_RPROXY_MAX_RESPONSE_TIME_MS", "1800000"),
}
if env.err != nil {
@@ -87,15 +90,15 @@ func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, erro
return cfg, nil
}
// RProxyListenAddrAndUpstreamURL reads only SWRP_LISTEN_ADDR and
// SWRP_UPSTREAM_URL. The health check in rproxy's container needs no other
// RProxyListenAddrAndUpstreamURL reads only SWW_RPROXY_LISTEN_ADDR and
// SWW_RPROXY_UPSTREAM_URL. The health check in rproxy's container needs no other
// setting, so it reads no other.
func RProxyListenAddrAndUpstreamURL(
lookupEnv func(string) (string, bool),
v *viper.Viper,
) (string, *url.URL, error) {
env := &environment{lookupEnv: withoutFileForms(lookupEnv)}
listenAddr := env.address("SWRP_LISTEN_ADDR")
upstreamURL := env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL)
env := &environment{v: v}
listenAddr := env.address("SWW_RPROXY_LISTEN_ADDR")
upstreamURL := env.appURL("SWW_RPROXY_UPSTREAM_URL", defaultUpstreamURL)
if env.err != nil {
return "", nil, env.err
@@ -104,20 +107,6 @@ func RProxyListenAddrAndUpstreamURL(
return listenAddr, upstreamURL, nil
}
// withoutFileForms returns lookupEnv finding no setting named with _FILE, since
// rproxy never touches disk.
func withoutFileForms(
lookupEnv func(string) (string, bool),
) func(string) (string, bool) {
return func(name string) (string, bool) {
if strings.HasSuffix(name, "_FILE") {
return "", false
}
return lookupEnv(name)
}
}
// LogValue makes an RProxy log as each setting's name with the value it was
// given, or its default.
func (c *RProxy) LogValue() slog.Value {
+36 -29
View File
@@ -10,20 +10,21 @@ import (
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
)
// rproxy's settings, by name, and their values in these tests.
const (
wafBackend = "SWRP_WAF_BACKEND"
rproxyTimeout = "SWRP_TIMEOUT_MS"
rproxyDefault = "SWRP_DEFAULT_DISPOSITION"
rproxyListen = "SWRP_LISTEN_ADDR"
rproxyUpstream = "SWRP_UPSTREAM_URL"
rproxyTrusted = "SWRP_TRUSTED_PROXIES"
rproxyMaxRequest = "SWRP_MAX_REQUEST_SIZE"
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE"
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS"
wafBackend = "SWW_RPROXY_WAF_BACKEND"
rproxyTimeout = "SWW_RPROXY_TIMEOUT_MS"
rproxyDefault = "SWW_RPROXY_DEFAULT_DISPOSITION"
rproxyListen = "SWW_RPROXY_LISTEN_ADDR"
rproxyUpstream = "SWW_RPROXY_UPSTREAM_URL"
rproxyTrusted = "SWW_RPROXY_TRUSTED_PROXIES"
rproxyMaxRequest = "SWW_RPROXY_MAX_REQUEST_SIZE"
rproxyMaxResponse = "SWW_RPROXY_MAX_RESPONSE_SIZE"
rproxyRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
rproxyAnswerTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
backendURL = "http://waf:8080"
rproxyDefaultAddr = ":8080"
rproxyDefaultApp = "http://127.0.0.1:8081"
@@ -32,11 +33,7 @@ const (
// rproxyConfig reads rproxy's settings from env.
func rproxyConfig(env map[string]string) (*config.RProxy, error) {
return config.RProxyFromEnvironment(func(name string) (string, bool) {
value, ok := env[name]
return value, ok
})
return config.RProxyFromEnvironment(configtest.Viper(env))
}
func TestRProxyDefaults(t *testing.T) {
@@ -177,31 +174,41 @@ func TestRProxyNeedsItsBackend(t *testing.T) {
}
}
func TestRProxyReadsNoFile(t *testing.T) {
func TestRProxySettingReadFromFile(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "backend")
err := os.WriteFile(path, []byte("http://from-a-file:8080\n"), 0o600)
err := os.WriteFile(path, []byte(backendURL+"\n"), 0o600)
if err != nil {
t.Fatalf("write: %v", err)
}
_, err = rproxyConfig(map[string]string{wafBackend + "_FILE": path})
if err == nil || !strings.HasPrefix(err.Error(), wafBackend+": is required") {
t.Errorf("error %v, want %s still required", err, wafBackend)
}
cfg, err := rproxyConfig(map[string]string{
wafBackend: backendURL,
rproxyTimeout + "_FILE": "/nonexistent/timeout",
})
cfg, err := rproxyConfig(map[string]string{wafBackend + "_FILE": path})
if err != nil {
t.Fatalf("settings: %v, with the file left unread", err)
t.Fatalf("settings: %v", err)
}
if cfg.Timeout != 2*time.Second {
t.Errorf("timeout %v, want the default", cfg.Timeout)
if cfg.WAFBackend.String() != backendURL {
t.Errorf("backend %v, want %s", cfg.WAFBackend, backendURL)
}
}
func TestRProxyUnreadableSettingFileStopsTheStart(t *testing.T) {
t.Parallel()
dir := t.TempDir()
for _, path := range []string{filepath.Join(dir, "missing"), dir} {
_, err := rproxyConfig(map[string]string{
wafBackend: backendURL,
rproxyTimeout + "_FILE": path,
})
want := rproxyTimeout + "_FILE: cannot be read: "
if err == nil || !strings.HasPrefix(err.Error(), want) {
t.Errorf("%s: error %v, want one starting %s", path, err, want)
}
}
}
+16
View File
@@ -0,0 +1,16 @@
package config
import "github.com/spf13/viper"
// NewViper returns the viper that reads the settings: each setting's
// environment variable, bound to its own name. Every setting of waf
// and rproxy is read through it, and none is read with os.Getenv or
// os.LookupEnv outside it. An environment variable that is set but empty is
// set, as one that holds a value is, which viper does not take for granted.
func NewViper() *viper.Viper {
v := viper.New()
v.AutomaticEnv()
v.AllowEmptyEnv(true)
return v
}
+34
View File
@@ -0,0 +1,34 @@
package config_test
import (
"testing"
"sneak.berlin/go/smallwebwaf/internal/config"
)
// These tests set real environment variables, so none runs in parallel.
func TestTokenSetEmptyInTheEnvironmentStopsTheStart(t *testing.T) {
t.Setenv(adminToken, "")
_, err := config.FromEnvironment(config.NewViper())
want := adminToken + ": is shorter than 32 characters"
if err == nil || err.Error() != want {
t.Fatalf("error %v, want %s", err, want)
}
}
func TestListSetEmptyInTheEnvironmentReplacesItsDefault(t *testing.T) {
t.Setenv(wafBackend, backendURL)
t.Setenv(rproxyTrusted, "")
cfg, err := config.RProxyFromEnvironment(config.NewViper())
if err != nil {
t.Fatalf("settings: %v", err)
}
if len(cfg.TrustedProxies) != 0 {
t.Errorf("trusted proxies %v, want none", cfg.TrustedProxies)
}
}
+28 -17
View File
@@ -1,7 +1,7 @@
// Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
// method, the URL with its query and the headers of a request, and on its
// body while SWW_WAF_BODY_LIMIT is set, with the fourteen changes sww
// makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as
// body while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, with the fifteen changes sww
// makes to it and the exclusions in SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE, as
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no
// response.
//
@@ -32,19 +32,19 @@ import (
// directives are the Core Rule Set as sww runs it, with the
// paranoia level for %d, bodyDirectives for the first %s while
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second,
// SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set, the exclusions file for the second,
// giteaExclusions for the third, wordpressExclusions for the fourth,
// drupalExclusions for the fifth, mediawikiExclusions for the sixth,
// mastodonExclusions for the seventh, vaultwardenExclusions for the eighth,
// and nextcloudExclusions for the ninth.
// nextcloudExclusions for the ninth, and forgejoExclusions for the tenth.
// Each rule sww adds has an id from 900000 to 900999, the ids the
// Core Rule Set keeps for the rules that set it up, which
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
// SWW_WAF_CORE_RULE_SET_DISABLED_RULES and the exclusions file refuse, so that no
// setting switches one off. Coraza joins a line ending in \ to the next,
// without the spaces at the start of the next.
const directives = `
# The engine only detects. sww compares the request's anomaly
# score with SWW_WAF_ANOMALY_THRESHOLD itself, in block and detect mode
# score with SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD itself, in block and detect mode
# alike. It reads no body, unless bodyDirectives switch that on.
SecRuleEngine DetectionOnly
SecRequestBodyAccess Off
@@ -102,7 +102,10 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
# So does the thirteenth.
%s
# And the fourteenth.
# So does the fourteenth.
%s
# And the fifteenth.
%s
# The sixth: only the rules for requests are loaded, and no response is
@@ -239,24 +242,32 @@ var vaultwardenExclusions string
//go:embed nextcloud.conf
var nextcloudExclusions string
// forgejoExclusions are the fifteenth change: the fields of Forgejo's own
// requests that skip the rules that refused them there, where Forgejo differs
// from gitea, as Coraza directives.
//
//go:embed forgejo.conf
var forgejoExclusions string
// cookiesNotRead are the cookies the Core Rule Set reads a request
// without, the rest of the fifth change.
// without, the rest of the fifth change. flash is Forgejo's name for
// gitea_flash.
//
//nolint:gochecknoglobals // a constant cannot be a list
var cookiesNotRead = []string{"gitea_flash", "redirect_to"}
var cookiesNotRead = []string{"gitea_flash", "flash", "redirect_to"}
// Params are what New needs.
type Params struct {
// ParanoiaLevel is SWW_WAF_PARANOIA_LEVEL, from 1 to 4.
// ParanoiaLevel is SWW_WAF_CORE_RULE_SET_PARANOIA_LEVEL, from 1 to 4.
ParanoiaLevel int
// DisabledRules are the ids of the rules switched off
// (SWW_WAF_DISABLED_RULES).
// (SWW_WAF_CORE_RULE_SET_DISABLED_RULES).
DisabledRules []int
// BodyLimit is the most of a request body the Core Rule Set reads
// (SWW_WAF_BODY_LIMIT), 0 while it is off and it reads none.
// (SWW_WAF_CORE_RULE_SET_BODY_LIMIT), 0 while it is off and it reads none.
BodyLimit int64
// ExclusionsFile is the path of a file of exclusions loaded before the
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
// Core Rule Set's rules (SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
// there is none.
ExclusionsFile string
}
@@ -282,7 +293,7 @@ var errSwwRuleID = errors.New("an id of sww's own rules")
// SecRuleRemoveById. The Core Rule Set's rules load after the file, so
// the directive finds none of them.
var errChangesNoRule = errors.New("the Core Rule Set's rules load after the file; " +
"switch a rule off with SWW_WAF_DISABLED_RULES, or on some requests " +
"switch a rule off with SWW_WAF_CORE_RULE_SET_DISABLED_RULES, or on some requests " +
"with a ctl exclusion")
// directivesIn returns the directives in text as Coraza reads them: it
@@ -352,7 +363,7 @@ type CoreRuleSet struct {
bodyLimit int64
}
// New returns the Core Rule Set with the fourteen changes, at params'
// New returns the Core Rule Set with the fifteen changes, at params'
// paranoia level, without the rules it switches off, reading request
// bodies up to params' limit, and with params' exclusions file loaded
// before the Core Rule Set's rules. An exclusions file is an error if it
@@ -382,7 +393,7 @@ func New(params Params) (*CoreRuleSet, error) {
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
giteaExclusions, wordpressExclusions, drupalExclusions,
mediawikiExclusions, mastodonExclusions, vaultwardenExclusions,
nextcloudExclusions)
nextcloudExclusions, forgejoExclusions)
if len(params.DisabledRules) > 0 {
ids := make([]string, len(params.DisabledRules))
@@ -470,7 +481,7 @@ func (c *CoreRuleSet) Inspect(
}
// readBody reads body, the body of the request in tx, which has run on
// the request's headers, while SWW_WAF_BODY_LIMIT is set and the body is
// the request's headers, while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set and the body is
// of a kind the Core Rule Set reads: form data and multipart, of which it
// reads the first c.bodyLimit bytes, and JSON and XML, which it reads only
// when they are no longer than that, since they cannot be read in part.
+18 -14
View File
@@ -21,7 +21,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
)
// defaultDisabledRules are the rules SWW_WAF_DISABLED_RULES switches off
// defaultDisabledRules are the rules SWW_WAF_CORE_RULE_SET_DISABLED_RULES switches off
// by default.
//
//nolint:gochecknoglobals // a constant cannot be a list
@@ -204,7 +204,7 @@ func atDefaults(t *testing.T) *coreruleset.CoreRuleSet {
}
// wantChange checks that crs lets through passes, a request one of the
// fourteen changes is for, and still finds result in refused, a request like
// fifteen changes is for, and still finds result in refused, a request like
// it that the change is not for.
func wantChange(
t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
@@ -370,7 +370,7 @@ func TestParameterNamesAreMatchedWithoutRegardToCase(t *testing.T) {
get("/?next=http://127.0.0.1:52341/"), matched(931100, 934110))
}
func TestCookiesGiteaFlashAndRedirectToAreNotRead(t *testing.T) {
func TestFlashAndRedirectToCookiesAreNotRead(t *testing.T) {
t.Parallel()
const (
@@ -380,8 +380,12 @@ func TestCookiesGiteaFlashAndRedirectToAreNotRead(t *testing.T) {
crs := atDefaults(t)
wantChange(t, crs, get("/owner/repo", "Cookie: gitea_flash="+flash),
get("/owner/repo", "Cookie: flash="+flash), matched(930120))
// flash is Forgejo's name for gitea_flash.
for _, name := range []string{"gitea_flash", "flash"} {
wantChange(t, crs, get("/owner/repo", "Cookie: "+name+"="+flash),
get("/owner/repo", "Cookie: message="+flash), matched(930120))
}
wantChange(t, crs, get("/", "Cookie: redirect_to="+redirectTo),
get("/", "Cookie: redirect="+redirectTo), matched(930120))
@@ -815,7 +819,7 @@ func misfiringPasswords(t *testing.T) []string {
func wantRandomPasswordsPass(t *testing.T, r request, name string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
crs := readingBodies(t)
misfiring := misfiringPasswords(t)
@@ -846,7 +850,7 @@ func wantRandomPasswordsPass(t *testing.T, r request, name string) {
func wantPasswordAttacksPass(t *testing.T, r request, names ...string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
for _, attack := range []string{
"' OR '1'='1", pathTraversal, "; cat /etc/passwd",
@@ -866,7 +870,7 @@ func wantPasswordAttacksPass(t *testing.T, r request, names ...string) {
func wantPasswordAttacksRefused(t *testing.T, r request, name string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
for _, attack := range []string{scriptTag, "javascript:alert(1)"} {
body := passwordBody(t, r, []string{name}, attack)
@@ -938,7 +942,7 @@ func TestGiteaPasswordsPass(t *testing.T) {
func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
crs := readingBodies(t)
@@ -986,7 +990,7 @@ func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
func TestPasswordExclusionsAreNarrow(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
crs := readingBodies(t)
@@ -1013,7 +1017,7 @@ func TestPasswordExclusionsAreNarrow(t *testing.T) {
func TestGiteaAttacksAreRefused(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
crs := atDefaults(t)
@@ -1070,7 +1074,7 @@ const (
)
// readingBodiesUpTo64K returns the Core Rule Set as sww runs it by default,
// but reading bodies up to the largest SWW_WAF_BODY_LIMIT.
// but reading bodies up to the largest SWW_WAF_CORE_RULE_SET_BODY_LIMIT.
func readingBodiesUpTo64K(t *testing.T) *coreruleset.CoreRuleSet {
t.Helper()
@@ -1137,7 +1141,7 @@ func wordpressAttackTargets(attack string) []string {
func TestWordPressAttacksAreRefused(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
@@ -1696,7 +1700,7 @@ func TestWordPressExclusionsAreNarrow(t *testing.T) {
wantBody(t, crs, signIn, body, matched(930120), body)
}
// bodyLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies.
// bodyLimit is SWW_WAF_CORE_RULE_SET_BODY_LIMIT in the tests that read bodies.
const bodyLimit = 8 << 10
// The Content-Type headers of the kinds of body the Core Rule Set reads.
+4 -4
View File
@@ -4,10 +4,10 @@
# request, before the rules it switches off, so this file is loaded before the
# Core Rule Set's rules. Each comment names a request a rule refused. An
# exclusion on a field of a body applies only when the query has no parameter
# of that name, so it matters only while SWW_WAF_BODY_LIMIT is set. The
# exclusions above the comment "The fields below are in bodies" also cover a
# body field of the same name whatever the query holds, and only those below it
# need a query without that name.
# of that name, so it matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is
# set. The exclusions above the comment "The fields below are in bodies" also
# cover a body field of the same name whatever the query holds, and only those
# below it need a query without that name.
#
# Each path may start with a language prefix, such as /fr in
# /fr/admin/content. The prefix is two or three letters, then optionally a
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"testing"
)
// drupalThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
// drupalThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
const drupalThreshold = 5
// drupalAddArticle is Drupal's form for a new article.
+21
View File
@@ -0,0 +1,21 @@
# The fifteenth change: on Forgejo's paths, some fields skip the rules that
# refused ordinary Forgejo requests there. Forgejo is a fork of gitea, and the
# eighth change's fixes for gitea apply to its requests too, so this file holds
# only what Forgejo sends and gitea does not. Each exclusion runs with the
# request, before the rules it switches off, so this file is loaded before the
# Core Rule Set's rules. Each comment names a request the rule refused.
# Forgejo's search of a wiki sends the text searched for in q, which skips
# these rules there:
# 930120: .htaccess
# 932120: powershell
# 932160: /bin/sh
# 932250: ls -la
# 932260: python3
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/wiki/search$" \
"id:900680,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:q,\
ctl:ruleRemoveTargetById=932120;ARGS:q,\
ctl:ruleRemoveTargetById=932160;ARGS:q,\
ctl:ruleRemoveTargetById=932250;ARGS:q,\
ctl:ruleRemoveTargetById=932260;ARGS:q"
+78
View File
@@ -0,0 +1,78 @@
package coreruleset_test
import (
"net/http"
"slices"
"testing"
)
// forgejoSearches are Forgejo's searches whose q skips some rules: the
// wiki's, from the fifteenth change, and the searches for a name, from the
// eighth.
//
//nolint:gochecknoglobals // a constant cannot be a list
var forgejoSearches = []string{
"/alice/tools/wiki/search",
"/explore/repos",
"/explore/users",
"/alice/tools/branches",
"/alice/-/packages",
"/api/v1/packages/alice",
"/alice",
}
// forgejoAttacks are an SQL injection, a path traversal, a script injection
// and a shell command after a ";", as query values.
//
//nolint:gochecknoglobals // a constant cannot be a list
var forgejoAttacks = []string{
sqlInjection, "..%2Fcustom%2Fconf%2Fapp.ini", scriptInjection, commandInjection,
}
func TestForgejoAttacksAreRefusedInItsSearches(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodies(t)
for _, path := range forgejoSearches {
for _, attack := range forgejoAttacks {
if got := inspect(t, defaults, get(path+"?q="+attack)); got.Score < threshold {
t.Errorf("GET %s?q=%s let through: %+v", path, attack, got)
}
// The same field in a form data body.
r := request{http.MethodPost, path, []string{formData}}
if got, _ := inspectBody(t, bodies, r, "q="+attack); got.Score < threshold {
t.Errorf("POST %s q=%s let through: %+v", path, attack, got)
}
}
}
}
func TestForgejoExclusionsAreNarrow(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
crs := atDefaults(t)
// /bin/sh and the hard values, which q lets through on the wiki's
// search, stay refused in q on paths near it and in another field on it.
// /alice/wiki/search would be the code search of a repository named
// wiki, where the eighth change lets q through. Forgejo has no owner
// named api, so /api/wiki/search is not one.
for _, target := range []string{
"/alice/tools/wiki/Home?q=",
"/alice/tools/wiki/search/x?q=",
"/api/wiki/search?q=",
"/alice/tools/wiki/search?other=",
} {
for _, value := range slices.Concat([]string{"%2Fbin%2Fsh"}, hardValues) {
if got := inspect(t, crs, get(target+value)); got.Score < threshold {
t.Errorf("%s%s let through: %+v", target, value, got)
}
}
}
}
+19 -4
View File
@@ -102,13 +102,16 @@ SecRule REQUEST_HEADERS:Referer "@rx ^https?://[^/]+/(?:explore/code|\
# name in q, which skips these rules there:
# 930120: .profile
# 932120: powershell-scripts
# 932250: ls -la
# 932260: python3-app
# 944130: org.apache.commons.logging
SecRule REQUEST_FILENAME "@rx ^/(?:explore/repos|user/search_candidates|\
[^/]+/-/packages|[^/]+/[^/]+/(?:branches|tags)|api/v1/repos/search)$" \
[^/]+/-/packages|[^/]+/[^/]+/(?:branches|tags)|api/v1/repos/search|\
api/v1/packages/[^/]+)$" \
"id:900602,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:q,\
ctl:ruleRemoveTargetById=932120;ARGS:q,\
ctl:ruleRemoveTargetById=932250;ARGS:q,\
ctl:ruleRemoveTargetById=932260;ARGS:q,\
ctl:ruleRemoveTargetById=944130;ARGS:q"
@@ -116,19 +119,31 @@ SecRule REQUEST_FILENAME "@rx ^/(?:explore/repos|user/search_candidates|\
# rules there:
# 930120: .profile
# 932120: powershell-scripts
# 932250: ls -la
# 932260: python3-app
# The searches:
# - the dashboard's filter of repositories;
# - the repositories on a user's or an organisation's page, whose path has
# one segment;
# - the author filter of the issue and pull request lists;
# - the lists of users and organisations, and the API's search for users.
SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+|repo/search|\
SecRule REQUEST_FILENAME "@rx ^/(?:repo/search|\
explore/(?:users|organizations)|api/v1/users/search|\
[^/]+/[^/]+/(?:issues|pulls)/posters)$" \
"id:900603,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:q,\
ctl:ruleRemoveTargetById=932120;ARGS:q,\
ctl:ruleRemoveTargetById=932250;ARGS:q,\
ctl:ruleRemoveTargetById=932260;ARGS:q"
# The repositories on a user's or an organisation's page are searched with q
# too. Their path has one segment, as other apps' pages such as /index.php do,
# so q keeps 932250 there and skips only these rules:
# 930120: .profile
# 932120: powershell-scripts
# 932260: python3-app
SecRule REQUEST_FILENAME "@rx ^/[^/]+$" \
"id:900604,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:q,\
ctl:ruleRemoveTargetById=932120;ARGS:q,\
ctl:ruleRemoveTargetById=932260;ARGS:q"
# These requests name a file in the path, which skips these rules there:
+2 -2
View File
@@ -4,8 +4,8 @@
# exclusion runs with the request, before the rules it switches off, so this
# file is loaded before the Core Rule Set's rules. Each comment names a request
# a rule refused. An exclusion on a field of a body applies only when the query
# has no parameter of that name, so it matters only while SWW_WAF_BODY_LIMIT is
# set.
# has no parameter of that name, so it matters only while
# SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
#
# Gitea serves /api/v1/ too, and Drupal serves /admin/. So only Mastodon's own
# resources under them count as Mastodon's paths.
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
)
// mastodonThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
// mastodonThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
const mastodonThreshold = 5
// mastodonStatuses is where Mastodon's apps and web app post.
+2 -2
View File
@@ -5,8 +5,8 @@
# request, before the rules it switches off, so this file is loaded before the
# Core Rule Set's rules. Each comment names a request a rule refused. An
# exclusion on a field of a body applies only when the query has no parameter of
# that name, so it matters only while SWW_WAF_BODY_LIMIT is set. MediaWiki's
# paths are matched at the site root and under /w/.
# that name, so it matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
# MediaWiki's paths are matched at the site root and under /w/.
#
# WordPress serves /index.php too. So /index.php counts as MediaWiki's only
# with a page title, in the query or after the path:
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"testing"
)
// mediawikiThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
// mediawikiThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
const mediawikiThreshold = 5
// mediawikiEdit is the address MediaWiki's edit form posts to.
+3 -2
View File
@@ -5,8 +5,9 @@
# switches off, so this file is loaded before the Core Rule Set's rules. Each
# comment names a request a rule refused. An exclusion on a field of a JSON body
# applies only when the query has no parameter of that name, so it matters only
# while SWW_WAF_BODY_LIMIT is set. Nextcloud's pages are matched at the site
# root and under /index.php/, where a Nextcloud without pretty URLs serves them.
# while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set. Nextcloud's pages are matched
# at the site root and under /index.php/, where a Nextcloud without pretty URLs
# serves them.
#
# Four rules read a search or a name as a command or a system file:
# 930120: .htaccess
+2 -2
View File
@@ -13,7 +13,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
)
// nextcloudThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
// nextcloudThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
const nextcloudThreshold = 5
// nextcloudSignIn is the sign-in page.
@@ -380,7 +380,7 @@ func TestNextcloudPasswordFieldsPass(t *testing.T) {
func TestNextcloudUserPasswordInValuePasses(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
crs := readingBodies(t)
target := nextcloudUsers + "/alice"
+21 -2
View File
@@ -50,7 +50,7 @@ func giteaRequests(t *testing.T) []request {
func TestOrdinaryGiteaRequestsPass(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
crs := atDefaults(t)
@@ -110,7 +110,7 @@ func recordedRequests(t *testing.T, name string) []recordedRequest {
func wantRecordedRequestsPass(t *testing.T, name string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodies(t)
@@ -149,6 +149,25 @@ func TestOrdinaryDrupalRequestsPass(t *testing.T) {
wantRecordedRequestsPass(t, "drupal-requests.txt")
}
// TestOrdinaryForgejoRequestsPass checks Forgejo's recorded requests at the
// defaults only. While bodies are read, the Core Rule Set still refuses the
// file and branch names some of them carry in a form or JSON body, as it does
// gitea's (TestGiteaBodiesTheCoreRuleSetRefuses).
func TestOrdinaryForgejoRequestsPass(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default
crs := atDefaults(t)
for _, recorded := range recordedRequests(t, "forgejo-requests.txt") {
r := request{recorded.Method, recorded.Target, recorded.Headers}
if got, _ := inspectBody(t, crs, r, recorded.Body); got.Score >= threshold {
t.Errorf("%s %s refused: %+v", r.method, r.target, got)
}
}
}
func TestOrdinaryMastodonRequestsPass(t *testing.T) {
t.Parallel()
+470
View File
@@ -0,0 +1,470 @@
# Ordinary Forgejo 17.0.1 requests that the Core Rule Set must let through at
# the defaults. They were recorded from a Forgejo driven by a headless Chrome,
# signed out, signed in and on the admin pages, by git over HTTP, by curl on
# the API and the generic package registry, and by crane on the container
# registry. Each line is one request as JSON. Names, addresses and passwords
# are invented. The browser's User-Agent is an ordinary Chrome's. A body or a
# multipart part that is not text is written as base64.
{"method": "GET", "target": "/", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/assets/js/webcomponents.js?v=17.0.1~gitea-1.22.0", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/assets/css/index.css?v=17.0.1~gitea-1.22.0", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/assets/css/theme-forgejo-auto.css?v=17.0.1~gitea-1.22.0", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/assets/js/index.js?v=17.0.1~gitea-1.22.0", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/assets/img/logo.svg", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/explore/repos", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/explore/users", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/avatars/c160f8cc69a4f0bf2b0362752353d060?size=128", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/explore/users", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/explore/organizations", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/user/login", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/user/forgot_password", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/user/sign_up", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "POST", "target": "/user/sign_up", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 91", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/sign_up", "Accept-Encoding: gzip, deflate"], "body": "user_name=bob&email=bob%40example.com&password=Wd7-kPq3-Zx8n-Rf2m&retype=Wd7-kPq3-Zx8n-Rf2m"}
{"method": "GET", "target": "/", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/sign_up", "Accept-Encoding: gzip, deflate", "Cookie: session=46035a1a58fc293d; lang=en-US; flash=success%3DAccount%2Bwas%2Bsuccessfully%2Bcreated.%2BWelcome%2521"], "body": ""}
{"method": "GET", "target": "/assets/js/eventsource.sharedworker.js?v=17.0.1~gitea-1.22.0", "headers": ["Host: example.com", "Connection: keep-alive", "Accept: */*", "Referer: http://example.com/", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept-Encoding: gzip, deflate", "Cookie: session=46035a1a58fc293d; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/css/dashboard-repo-list.98057021.css", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: session=46035a1a58fc293d; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/dashboard-repo-list.09995173.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: session=46035a1a58fc293d; lang=en-US"], "body": ""}
{"method": "GET", "target": "/avatars/4b9bb80620f03eb3719e0a061c14283d?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: session=46035a1a58fc293d; lang=en-US"], "body": ""}
{"method": "GET", "target": "/repo/search?count_only=1&uid=2&team_id=undefined&q=&page=1&mode=", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: session=46035a1a58fc293d; lang=en-US"], "body": ""}
{"method": "GET", "target": "/user/events", "headers": ["Host: example.com", "Connection: keep-alive", "Pragma: no-cache", "Cache-Control: no-cache", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/event-stream", "Referer: http://example.com/assets/js/eventsource.sharedworker.js?v=17.0.1~gitea-1.22.0", "Accept-Encoding: gzip, deflate", "Cookie: session=46035a1a58fc293d; lang=en-US"], "body": ""}
{"method": "GET", "target": "/repo/search?sort=updated&order=desc&uid=2&team_id=undefined&q=&page=1&limit=15&mode=&archived=false", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/user/login", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: redirect_to=%2Frepo%2Fsearch%3Fsort%3Dupdated%26order%3Ddesc%26uid%3D2%26team_id%3Dundefined%26q%3D%26page%3D1%26limit%3D15%26mode%3D%26archived%3Dfalse"], "body": ""}
{"method": "POST", "target": "/user/login", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 43", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/login", "Accept-Encoding: gzip, deflate", "Cookie: redirect_to=%2Frepo%2Fsearch%3Fsort%3Dupdated%26order%3Ddesc%26uid%3D2%26team_id%3Dundefined%26q%3D%26page%3D1%26limit%3D15%26mode%3D%26archived%3Dfalse"], "body": "user_name=alice&password=Hv4-mQz8-Lr2x-Tp9w"}
{"method": "GET", "target": "/repo/search?sort=updated&order=desc&uid=2&team_id=undefined&q=&page=1&limit=15&mode=&archived=false", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/login", "Accept-Encoding: gzip, deflate", "Cookie: session=cf1a51e0caee441a; lang=en-US"], "body": ""}
{"method": "GET", "target": "/repo/create", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=cf1a51e0caee441a; lang=en-US"], "body": ""}
{"method": "GET", "target": "/avatars/c160f8cc69a4f0bf2b0362752353d060?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/repo/create", "Accept-Encoding: gzip, deflate", "Cookie: session=cf1a51e0caee441a; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/webcomponents.js?v=17.0.1~gitea-1.22.0", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/user/login", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/assets/css/index.css?v=17.0.1~gitea-1.22.0", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/user/login", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/assets/js/index.js?v=17.0.1~gitea-1.22.0", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/user/login", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/assets/css/theme-forgejo-auto.css?v=17.0.1~gitea-1.22.0", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/user/login", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/assets/img/logo.svg", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/user/login", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/login", "Accept-Encoding: gzip, deflate", "Cookie: session=6f0c5162ff135818; lang=en-US"], "body": ""}
{"method": "GET", "target": "/avatars/c160f8cc69a4f0bf2b0362752353d060?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: session=6f0c5162ff135818; lang=en-US"], "body": ""}
{"method": "GET", "target": "/repo/search?count_only=1&uid=1&team_id=undefined&q=&page=1&mode=", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: session=6f0c5162ff135818; lang=en-US"], "body": ""}
{"method": "GET", "target": "/repo/search?sort=updated&order=desc&uid=1&team_id=undefined&q=&page=1&limit=15&mode=&archived=false", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: session=6f0c5162ff135818; lang=en-US"], "body": ""}
{"method": "POST", "target": "/repo/create", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 179", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/repo/create", "Accept-Encoding: gzip, deflate", "Cookie: session=97fc659c225db684; lang=en-US"], "body": "uid=1&repo_name=tools&description=Build+scripts+and+notes&repo_template=&auto_init=on&gitignores=&license=&readme=Default&issue_labels=&object_format_name=sha1&default_branch=main"}
{"method": "GET", "target": "/alice/tools", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/repo/create", "Accept-Encoding: gzip, deflate", "Cookie: session=97fc659c225db684; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/css/repo-branch-tag-selector.d9ca1da2.css", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/alice/tools", "Accept-Encoding: gzip, deflate", "Cookie: session=97fc659c225db684; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/repo-branch-tag-selector.f396185d.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools", "Accept-Encoding: gzip, deflate", "Cookie: session=97fc659c225db684; lang=en-US"], "body": ""}
{"method": "GET", "target": "/org/create", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=97fc659c225db684; lang=en-US"], "body": ""}
{"method": "POST", "target": "/org/create", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 59", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/org/create", "Accept-Encoding: gzip, deflate", "Cookie: session=97fc659c225db684; lang=en-US"], "body": "org_name=acme&visibility=0&repo_admin_change_team_access=on"}
{"method": "GET", "target": "/org/acme/dashboard", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/org/create", "Accept-Encoding: gzip, deflate", "Cookie: session=97fc659c225db684; lang=en-US"], "body": ""}
{"method": "GET", "target": "/avatars/53bce4f1dfa0fe8e7ca126f91b35d3a6?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/org/acme/dashboard", "Accept-Encoding: gzip, deflate", "Cookie: session=97fc659c225db684; lang=en-US"], "body": ""}
{"method": "GET", "target": "/repo/search?count_only=1&uid=3&team_id=undefined&q=&page=1&mode=", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/org/acme/dashboard", "Accept-Encoding: gzip, deflate", "Cookie: session=97fc659c225db684; lang=en-US"], "body": ""}
{"method": "GET", "target": "/repo/search?sort=updated&order=desc&uid=3&team_id=undefined&q=&page=1&limit=15&mode=&archived=false", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/org/acme/dashboard", "Accept-Encoding: gzip, deflate", "Cookie: session=97fc659c225db684; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools.git/info/refs?service=git-upload-pack", "headers": ["Host: example.com", "User-Agent: git/2.49.1", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "Pragma: no-cache", "Git-Protocol: version=2"], "body": ""}
{"method": "POST", "target": "/alice/tools.git/git-upload-pack", "headers": ["Host: example.com", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-upload-pack-request", "Accept: application/x-git-upload-pack-result", "Git-Protocol: version=2", "Content-Length: 181"], "body": "0014command=ls-refs\n001aagent=git/2.49.1-Linux0016object-format=sha100010009peel\n000csymrefs\n000bunborn\n001bref-prefix refs/heads/\n001aref-prefix refs/tags/\n0014ref-prefix HEAD\n0000"}
{"method": "POST", "target": "/alice/tools.git/git-upload-pack", "headers": ["Host: example.com", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-upload-pack-request", "Accept: application/x-git-upload-pack-result", "Git-Protocol: version=2", "Content-Length: 223"], "body": "0011command=fetch001aagent=git/2.49.1-Linux0016object-format=sha10001000dthin-pack000fno-progress000dofs-delta0032want 025ecb5715ad88750bc8944ae3ee0023776b08a3\n0032want 025ecb5715ad88750bc8944ae3ee0023776b08a3\n0009done\n0000"}
{"method": "GET", "target": "/alice/tools.git/info/refs?service=git-receive-pack", "headers": ["Host: example.com", "User-Agent: git/2.49.1", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "Pragma: no-cache"], "body": ""}
{"method": "POST", "target": "/alice/tools.git/git-receive-pack", "headers": ["Host: example.com", "Authorization: Basic YWxpY2U6SHY0LW1RejgtTHIyeC1UcDl3", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-receive-pack-request", "Accept: application/x-git-receive-pack-result", "Content-Length: 1195"], "body": "MDBiNTAyNWVjYjU3MTVhZDg4NzUwYmM4OTQ0YWUzZWUwMDIzNzc2YjA4YTMgYmJlNTIyM2E5YTRmODRlN2MzZDcyMDZmNGJkZDA2MjIzOWU4MzA5YiByZWZzL2hlYWRzL21haW4AIHJlcG9ydC1zdGF0dXMtdjIgc2lkZS1iYW5kLTY0ayBxdWlldCBvYmplY3QtZm9ybWF0PXNoYTEgYWdlbnQ9Z2l0LzIuNDkuMS1MaW51eDAwMDBQQUNLAAAAAgAAAA+YDXiclYtbCsIwEAD/c4r8C7J5bLMFEXuUzWaLgVZLmoLHtx7B+RkYmN5UrRTyOCtLiSKEgCPMPnoix7FIRBiT5hSd2bjpq1vwqJIxOeRClBCy0Bgja1AF8CGlIQNxMHz057vZaami9sY/PfTD67boVd7r3bo0xkAOh8Fe4MScda2961+TmUqx+ahLsbu0uvXdfAHWnz/YoRR4nDM0MDAzMVHQS88syUzPyy9KZTiU2lsrbdjSY104jdX5NHPG05wrxw2hqjJKEpOTU4uLGaIm9ecuijp2bVeqB3fZBavC0xMSLaCKglwdXXxd9XJTGIra7y45/9VpvTvn0Xl2Kzsfiq1znAJVFJ1SlJhWEgtS9fmKeemzqGM9rJ0rpsnMS4t74ZzpYmIABApJmXkMS35PtDvsY3r29PzXKw5bNU9ZxpERBzUjJT85O7VINzk/tyC/OFWvMjeHQWd5y3flxTNnR91tzrz0g3Vzj8LbmxCzgIqLGfhebpt28ZJ3sYB3caHp4gO26QUZiyDSxclFmQUlxQxfH11qDNt8SW+e8ule1UMm87rDHz6HqihKZkjJPfl/b6+HvVWbwP4LLHLrGh0WWwIAQ5+D9D54nMvLT0mNz81PKc1JLdbnAgAqqgU4sQF4nPMvKMnMzytW0PXMS0mtSC3mAgA5BQYUNnicSylKTCvhAgAIQAIcrQJ4nDM0MDAzMVFIyU/OTi3STc0rKaosyM/MK2EwlNlx2uGU5d7E5T2We5qlPpa4xEUBAGp2Eai0AXicU1bUT8rM0y/O4EqtSE1WUFJxUOICADrbBTiyAnicK04tKstMTi224lJQKE9NAlEKCpm5iempVgp56Zl5FVwAvLIKdq8CeJwzNDAwMzFRyEosS9TLScxL1wsuKcoEUrkpDDqcykduszK1ZnX7O/2+3DJ3wducWABo9hG0OXicU1YILinKzEvnAgANQQLFqQV4nDM0MDAzMVHIzCsuSczJ0S1OLsosKNErzmA4WsLziFHnpxzTZC5LhjXrhBeuDpcyhCguqCzJyM8z1k3OSE3O1iuoZNjBZ6S0Ws37+qnf5umeeyUa/a+llwEAQ3sf9roBeJxTVtRPyszTL87gSk3OyFfIzCsuSczJycxL5wIAbs0Ivzx4nCsoyswr0VDKyFTS5AIAHBwDnqwCeJwzNDAwMzFRKCjKT04tLtZLzSvTS9ErKWZoZrzwV9ki/dEqV+5di3c4P5qYE/gAAFOgEcS7AnicS0lNzkksSlVIzs8rLlFIzSuzUghKTc4vSrEpLinKzEvXUYDQdtZcAF6zD3MdeaAnWyIMS8cNQ71Ry9eDVrG43w=="}
{"method": "POST", "target": "/alice/tools.git/git-receive-pack", "headers": ["Host: example.com", "Authorization: Basic YWxpY2U6SHY0LW1RejgtTHIyeC1UcDl3", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-receive-pack-request", "Accept: application/x-git-receive-pack-result", "Content-Length: 617"], "body": "MDBjMjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAgZjdmZmE2MDM5NmQ5OWY2MTMxNWE1YTlhMjI1ZmMzMmViNjIyOGZkYiByZWZzL2hlYWRzL3B5dGhvbjMtbWlncmF0aW9uACByZXBvcnQtc3RhdHVzLXYyIHNpZGUtYmFuZC02NGsgcXVpZXQgb2JqZWN0LWZvcm1hdD1zaGExIGFnZW50PWdpdC8yLjQ5LjEtTGludXgwMDAwUEFDSwAAAAIAAAAElg14nJWNSwoCMRBE9zlF7wVJ0j35gIgewEMk6Q4OzJhhiKK3Nx7B2hS8R1F9F4GKwoGzUAmFinHOMCUO1gyKjrC6yDb6qLa0y6NDzjJZiykmqoHEF2RvtauUmbUbJkpAHbNKz35vO1yXuQic0q8u8k7rtsixtPUMxkfCYCbn4aBH1KDr3Lv8NVK39hLoDbbP+Hug+gLJoT+v+gPNgl/qzUzIUFkPJCiBpNxFCX63QXicO8h0kGkDP6NRbPrloHwvB+O3XZczPxVe7CwzvnPLxAAIFIqLkhlSck/+39vrYW/VJrD/AovcukaHxZYA3WMY768HeJwzNDAwMzFRyMwrLknMydEtTi7KLCjRK85gOFrC84hR56cc02QuS4Y164QXrg6XMoQozs1ML0osSdUrqGTYKb5c7aT9BD1nTdkJnNc8mluFrdOhqgoqSzLy84x1kzNSk7NBanfwGSmtVvO+fuq3ebrnXolG/2vpZQCoeiylOXicKyjKzCvRMNTkAgAQzwK6yhVHjb1hxkRjVbbQfVlakFC62Bw="}
{"method": "POST", "target": "/alice/tools.git/git-receive-pack", "headers": ["Host: example.com", "Authorization: Basic YWxpY2U6SHY0LW1RejgtTHIyeC1UcDl3", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-receive-pack-request", "Accept: application/x-git-receive-pack-result", "Content-Length: 475"], "body": "MDBjNDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAgNGI1ZWMxYWFiNjUyNzJiNjNiNWIxZjM5YmZjMmMxNDY4NGMwNjNhMCByZWZzL2hlYWRzL2ZlYXQvZG9ja2VyLXN1cHBvcnQAIHJlcG9ydC1zdGF0dXMtdjIgc2lkZS1iYW5kLTY0ayBxdWlldCBvYmplY3QtZm9ybWF0PXNoYTEgYWdlbnQ9Z2l0LzIuNDkuMS1MaW51eDAwMDBQQUNLAAAAAgAAAAOVDXiclYtbCsIwEEX/s4r8CzLNswERC25kJjODxdaWEMHlW5fg/TlwDrc3EesIQyoeoOQQaXCclFExhsqOKEQFUFJwZscmr26JJDrnsWDQMUiunrODpIGYIR2lyOihkMF3f2zNTstcxV7wh5t8cN0XOddtvdohl+DHIaZsT3DMHHade5e/TmZitvetPqXpvIj5AnIRP0P5As2CX+rNTMhQWQ8kKIGk3EUJfrdBeJw7yPScaUKQvEt+cnZqUVpmTipD+l/zCRITa+JfuB2Sut6UzLn8RVLLRO9vAGSzEms8eJxzC/L3VUjMKcjMS+UCABjGA9h6SWeBbtf5prUzWzGtbNHU1VZzAQ=="}
{"method": "POST", "target": "/alice/tools.git/git-receive-pack", "headers": ["Host: example.com", "Authorization: Basic YWxpY2U6SHY0LW1RejgtTHIyeC1UcDl3", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-receive-pack-request", "Accept: application/x-git-receive-pack-result", "Content-Length: 218"], "body": "MDBiNjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAgYmJlNTIyM2E5YTRmODRlN2MzZDcyMDZmNGJkZDA2MjIzOWU4MzA5YiByZWZzL3RhZ3MvdjEuMC4wACByZXBvcnQtc3RhdHVzLXYyIHNpZGUtYmFuZC02NGsgcXVpZXQgb2JqZWN0LWZvcm1hdD1zaGExIGFnZW50PWdpdC8yLjQ5LjEtTGludXgwMDAwUEFDSwAAAAIAAAAAAp0IgjvYqOq1EK1qx1yCPP0+0x4="}
{"method": "GET", "target": "/alice/powershell-scripts.git/info/refs?service=git-receive-pack", "headers": ["Host: example.com", "User-Agent: git/2.49.1", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "Pragma: no-cache"], "body": ""}
{"method": "POST", "target": "/alice/powershell-scripts.git/git-receive-pack", "headers": ["Host: example.com", "Authorization: Basic YWxpY2U6SHY0LW1RejgtTHIyeC1UcDl3", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-receive-pack-request", "Accept: application/x-git-receive-pack-result", "Content-Length: 397"], "body": "MDBiNTAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAgZGQxYzIyM2RjZWI1NDQxMjdiNzhjZGMyYTZjZTM1NmY4ZWQ5NGYyZCByZWZzL2hlYWRzL21haW4AIHJlcG9ydC1zdGF0dXMtdjIgc2lkZS1iYW5kLTY0ayBxdWlldCBvYmplY3QtZm9ybWF0PXNoYTEgYWdlbnQ9Z2l0LzIuNDkuMS1MaW51eDAwMDBQQUNLAAAAAgAAAAObCXiclYtRCsIwEAX/c4r9FyTbmO0GRPQoSXzBQGOlrODxrUdwfgYGxjaARNsEbjJ5pCRVYp0DtxCKIkfPUqLXO6O4/LbHutFt6RV0zj9d8cnjteBY13EhntMpKEdROvgdt9fRzfDX5Pqzm/sCJg8twKUCeJwzNDAwMzFRCHJ1dPF11ctNYXDttjkS9vTmppsC4WHfVpXW8TJZuQIA3ioNSjp4nFNWKE4uyiwoKeYCABGaA1YUrPZOD6Y21E0qY7z1OfdB9DOSug=="}
{"method": "POST", "target": "/alice/tools.git/git-upload-pack", "headers": ["Host: example.com", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-upload-pack-request", "Accept: application/x-git-upload-pack-result", "Git-Protocol: version=2", "Content-Length: 212"], "body": "0014command=ls-refs\n001aagent=git/2.49.1-Linux0016object-format=sha100010009peel\n000csymrefs\n000bunborn\n001bref-prefix refs/heads/\n001fref-prefix refs/heads/main\n001aref-prefix refs/tags/\n0014ref-prefix HEAD\n0000"}
{"method": "GET", "target": "/assets/js/activity-heatmap.514f8f16.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/branch/main/.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/branch/main/docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/branch/main/scripts/install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/branch/main/bin/docker-entrypoint", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/branch/main/src/process.env.d.ts", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/branch/main/docs/java.lang.String.md", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/branch/main/%5Bdraft%5D.md", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/raw/branch/main/.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/raw/branch/main/scripts/install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/blame/branch/main/docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/commits/branch/main/scripts/install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/branch/python3-migration", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/branch/feat/docker-support/Dockerfile", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/commits/branch/main", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/branches", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/tags", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/releases", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/activity", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/repo-activity-top-authors-contributors-graph-code-frequency-graph-recent-commits-graph.2afcdb46.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/activity", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/repo-activity-top-authors.0b8b147c.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/activity", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/avatars/c160f8cc69a4f0bf2b0362752353d060", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/activity", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/graph", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/compare/main...python3-migration", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/css/dropzone.5a752d14.css", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/alice/tools/compare/main...python3-migration", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/dropzone.265e21d7.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/compare/main...python3-migration", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/tag/v1.0.0", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/archive/main.zip", "headers": ["user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "accept: */*", "accept-encoding: gzip,deflate,br", "cookie: session=d65379616ffd0971; lang=en-US", "Host: example.com", "Connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/alice/tools/search?q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/repos?q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/code?q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/issues?type=all&state=open&q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/pulls?state=open&q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/issues?type=your_repositories&q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/branches?q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/users?q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/organizations?q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice?tab=repositories&q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/avatars/53bce4f1dfa0fe8e7ca126f91b35d3a6?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice?tab=repositories&q=python3", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/commits/branch/main/search?q=python3&all=true", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/-/packages?q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/wiki/search?q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search?q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/repos?q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/code?q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/issues?type=all&state=open&q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/pulls?state=open&q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/issues?type=your_repositories&q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/branches?q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/users?q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/organizations?q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice?tab=repositories&q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/commits/branch/main/search?q=powershell&all=true", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/-/packages?q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/wiki/search?q=powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search?q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/repos?q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/code?q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/issues?type=all&state=open&q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/pulls?state=open&q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/issues?type=your_repositories&q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/branches?q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/users?q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/organizations?q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/commits/branch/main/search?q=ls%20-la&all=true", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/-/packages?q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/wiki/search?q=ls%20-la", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search?q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/repos?q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/code?q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/issues?type=all&state=open&q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/pulls?state=open&q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/issues?type=your_repositories&q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/branches?q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/users?q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/organizations?q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice?tab=repositories&q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/commits/branch/main/search?q=.htaccess&all=true", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/-/packages?q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/wiki/search?q=.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search?q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/repos?q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/code?q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/issues?type=all&state=open&q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/pulls?state=open&q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/issues?type=your_repositories&q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/branches?q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/users?q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/organizations?q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice?tab=repositories&q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/commits/branch/main/search?q=docker-compose.yml&all=true", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/-/packages?q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/wiki/search?q=docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search?q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/repos?q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/code?q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/issues?type=all&state=open&q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/pulls?state=open&q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/issues?type=your_repositories&q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/branches?q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/users?q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/organizations?q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice?tab=repositories&q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/commits/branch/main/search?q=install-script.sh&all=true", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/-/packages?q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/wiki/search?q=install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search?q=%2Fbin%2Fsh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/code?q=%2Fbin%2Fsh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/issues?type=all&state=open&q=%2Fbin%2Fsh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/pulls?state=open&q=%2Fbin%2Fsh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/issues?type=your_repositories&q=%2Fbin%2Fsh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/commits/branch/main/search?q=%2Fbin%2Fsh&all=true", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/wiki/search?q=%2Fbin%2Fsh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/search?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/repos?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/code?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/issues?type=all&state=open&q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/pulls?state=open&q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/issues?type=your_repositories&q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/branches?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/users?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/explore/organizations?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice?tab=repositories&q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/commits/branch/main/search?q=release%20notes&all=true", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/-/packages?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/wiki/search?q=release%20notes", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/_edit/main/docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/codemirror-----------------.850d6e6e.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/codemirror.f4be46b8.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/avatars/c160f8cc69a4f0bf2b0362752353d060?size=128", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/----------------.de1a9813.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/vendors-node_modules_codemirror_lang-yaml_dist_index_js.4f1c0b18.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "POST", "target": "/alice/tools/_edit/main/docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 278", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "last_commit=bbe5223a9a4f84e7c3d7206f4bdd062239e8309b&page_has_posted=&tree_path=docker-compose.yml&content=services%3A%0D%0A++web%3A%0D%0A++++image%3A+nginx%3A1.27%0D%0A&commit_summary=Pin+nginx&commit_message=&commit_choice=direct&new_branch_name=alice-patch-1&commit_mail_id=1"}
{"method": "GET", "target": "/alice/tools/src/branch/main/docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/_new/main/", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "POST", "target": "/alice/tools/_new/main/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 272", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_new/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "last_commit=2a8b9144333b5cb8075a74c64b31dbaa7ea02b3c&page_has_posted=&tree_path=docs%2Fpython3-setup.md&content=%23+Setup%0D%0A%0D%0AInstall+the+tools.%0D%0A&commit_summary=Add+setup+notes&commit_message=&commit_choice=direct&new_branch_name=alice-patch-1&commit_mail_id=1"}
{"method": "GET", "target": "/assets/js/---.8673fbd3.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/_new/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/------.2befb329.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/_new/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/vendors-node_modules_codemirror_lang-markdown_dist_index_js.eef88d50.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/_new/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/branch/main/docs/python3-setup.md", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_new/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/_upload/main/", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "POST", "target": "/alice/tools/upload-file", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 224", "Cache-Control: no-cache", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: application/json", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundary4q9JlyMIwlkOQ1ZY", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_upload/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "------WebKitFormBoundary4q9JlyMIwlkOQ1ZY\r\nContent-Disposition: form-data; name=\"file\"; filename=\"notes.txt\"\r\nContent-Type: text/plain\r\n\r\nSteps to reproduce: run the build twice.\n\r\n------WebKitFormBoundary4q9JlyMIwlkOQ1ZY--\r\n"}
{"method": "POST", "target": "/alice/tools/_upload/main/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 229", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_upload/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "tree_path=&files=cde01135-f509-483d-af4a-56d1f754aa2c&files_fullpath%5Bcde01135-f509-483d-af4a-56d1f754aa2c%5D=notes.txt&commit_summary=Add+notes&commit_message=&commit_choice=direct&new_branch_name=alice-patch-1&commit_mail_id=1"}
{"method": "GET", "target": "/alice/tools/src/branch/main/", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_upload/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/labels", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/css/colorpicker.6c3c1b7d.css", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/alice/tools/labels", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/colorpicker.eb94c3c5.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/labels", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/milestones/new", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "POST", "target": "/alice/tools/milestones/new", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 36", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/milestones/new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "title=Version+1.1&deadline=&content="}
{"method": "GET", "target": "/alice/tools/milestones", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/milestones/new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US; flash=success%3DThe%2Bmilestone%2B%2522Version%2B1.1%2522%2Bhas%2Bbeen%2Bcreated."], "body": ""}
{"method": "GET", "target": "/alice/tools/issues/new", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "POST", "target": "/alice/tools/issues/attachments", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 5018", "Cache-Control: no-cache", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: application/json", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryZATiKc8Kvm2f9kjJ", "Origin: http://example.com", "Referer: http://example.com/alice/tools/issues/new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "------WebKitFormBoundaryZATiKc8Kvm2f9kjJ\r\nContent-Disposition: form-data; name=\"file\"; filename=\"screenshot.png\"\r\nContent-Type: image/png\r\n\r\niVBORw0KGgoAAAANSUhEUgAAAEAAAAAoCAIAAADBrGu+AAASpklEQVR4nBXYkZ+0UBTG8YENNthggwk22GCCDSaYYJIkSZIkSZIkSX6SJEmSJEmSJEmSJMnKyMjKysrKyMjIyrzz9hfc7/2ce85z2mw2CBvEDdIGeYOyQd2gbdA3GBvMDdYGe4Ozwd3gbfA3BBvCDdGGeEOyId2QbWBDvqHYUG6oNtQbmg3thm5Dv2HYMG6YNswblg3rhs8Npw3nDV8bvjf8bPjdcNlw3XDb8LfhvuFpw/OGlw2vG7Yb3ja8b9ht+Niw33DYcNyw2QgIAqKAJCALKAKqgCagCxgCpoAlYAs4Aq6AJ+ALBAKhQCQQCyQCqUAmgEAuUAiUApVALdAItAKdQC8wCIwCk8AssAisAp8CJ4GzwJfAt8CPwK/AReAqcBP4E7gLPAk8C7wIvApsBd4E3gV2Ah8Ce4GDwFF4AEQEEVFEEpFFFBFVRBPRRQwRU8QSsUUcEVfEE/FFApFQJBKJRRKRVCQTQSQXKURKkUqkFmlEWpFOpBcZREaRSWQWWURWkU+Rk8hZ5EvkW+RH5FfkInIVuYn8idxFnkSeRV5EXkW2Im8i7yI7kQ+RvchB5Cg+ABKChCghScgSioQqoUnoEoaEKWFJ2BKOhCvhSfgSgUQoEUnEEolEKpFJIJFLFBKlRCVRSzQSrUQn0UsMEqPEJDFLLBKrxKfESeIs8SXxLfEj8StxkbhK3CT+JO4STxLPEi8SrxJbiTeJd4mdxIfEXuIgcZQeABlBRpSRZGQZRUaV0WR0GUPGlLFkbBlHxpXxZHyZQCaUiWRimUQmlclkkMllCplSppKpZRqZVqaT6WUGmVFmkpllFplV5lPmJHOW+ZL5lvmR+ZW5yFxlbjJ/MneZJ5lnmReZV5mtzJvMu8xO5kNmL3OQOcoPgIKgICpICrKCoqAqaAq6gqFgKlgKtoKj4Cp4Cr5CoBAqRAqxQqKQKmQKKOQKhUKpUCnUCo1Cq9Ap9AqDwqgwKcwKi8Kq8KlwUjgrfCl8K/wo/CpcFK4KN4U/hbvCk8KzwovCq8JW4U3hXWGn8KGwVzgoHJUHQEVQEVUkFVlFUVFVNBVdxVAxVSwVW8VRcVU8FV8lUAlVIpVYJVFJVTIVVHKVQqVUqVRqlUalVelUepVBZVSZVGaVRWVV+VQ5qZxVvlS+VX5UflUuKleVm8qfyl3lSeVZ5UXlVWWr8qbyrrJT+VDZqxxUjuoDoCFoiBqShqyhaKgamoauYWiYGpaGreFouBqehq8RaIQakUaskWikGpkGGrlGoVFqVBq1RqPRanQavcagMWpMGrPGorFqfGqcNM4aXxrfGj8avxoXjavGTeNP467xpPGs8aLxqrHVeNN419hpfGjsNQ4aR+0B0BF0RB1JR9ZRdFQdTUfXMXRMHUvH1nF0XB1Px9cJdEKdSCfWSXRSnUwHnVyn0Cl1Kp1ap9FpdTqdXmfQGXUmnVln0Vl1PnVOOmedL51vnR+dX52LzlXnpvOnc9d50nnWedF51dnqvOm86+x0PnT2Ogedo/4AGAgGooFkIBsoBqqBZqAbGAamgWVgGzgGroFn4BsEBqFBZBAbJAapQWaAQW5QGJQGlUFt0Bi0Bp1BbzAYjAaTwWywGKwGnwYng7PBl8G3wY/Br8HF4GpwM/gzuBs8GTwbvBi8GmwN3gzeDXYGHwZ7g4PB0XgATAQT0UQykU0UE9VEM9FNDBPTxDKxTRwT18Qz8U0Ck9AkMolNEpPUJDPBJDcpTEqTyqQ2aUxak86kNxlMRpPJZDZZTFaTT5OTydnky+Tb5Mfk1+RicjW5mfyZ3E2eTJ5NXkxeTbYmbybvJjuTD5O9ycHkaD4AFoKFaCFZyBaKhWqhWegWhoVpYVnYFo6Fa+FZ+BaBRWgRWcQWiUVqkVlgkVsUFqVFZVFbNBatRWfRWwwWo8VkMVssFqvFp8XJ4mzxZfFt8WPxa3GxuFrcLP4s7hZPFs8WLxavFluLN4t3i53Fh8Xe4mBxtB4AG8FGtJFsZBvFRrXRbHQbw8a0sWxsG8fGtfFsfJvAJrSJbGKbxCa1yWywyW0Km9KmsqltGpvWprPpbQab0WaymW0Wm9Xm0+Zkc7b5svm2+bH5tbnYXG1uNn82d5snm2ebF5tXm63Nm827zc7mw2Zvc7A52g+Ag+AgOkgOsoPioDpoDrqD4WA6WA62g+PgOngOvkPgEDpEDrFD4pA6ZA445A6FQ+lQOdQOjUPr0Dn0DoPD6DA5zA6Lw+rw6XByODt8OXw7/Dj8Olwcrg43hz+Hu8OTw7PDi8Orw9bhzeHdYefw4bB3ODgcnQfARXARXSQX2UVxUV00F93FcDFdLBfbxXFxXTwX3yVwCV0il9glcUldMhdccpfCpXSpXGqXxqV16Vx6l8FldJlcZpfFZXX5dDm5nF2+XL5dflx+XS4uV5eby5/L3eXJ5dnlxeXVZevy5vLusnP5cNm7HFyO7gPgIXiIHpKH7KF4qB6ah+5heJgeloft4Xi4Hp6H7xF4hB6RR+yReKQemQceuUfhUXpUHrVH49F6dB69x+Axekwes8fisXp8epw8zh5fHt8ePx6/HhePq8fN48/j7vHk8ezx4vHqsfV483j32Hl8eOw9Dh5H7wHwEXxEH8lH9lF8VB/NR/cxfEwfy8f2cXxcH8/H9wl8Qp/IJ/ZJfFKfzAef3KfwKX0qn9qn8Wl9Op/eZ/AZfSaf2WfxWX0+fU4+Z58vn2+fH59fn4vP1efm8+dz93nyefZ58Xn12fq8+bz77Hw+fPY+B5+j/wAECAFigBQgBygBaoAWoAcYAWaAFWAHOAFugBfgBwQBYUAUEAckAWlAFkBAHlAElAFVQB3QBLQBXUAfMASMAVPAHLAErAGfAaeAc8BXwHfAT8BvwCXgGnAL+Au4BzwFPAe8BLwGbAPeAt4DdgEfAfuAQ8AxeABChBAxRAqRQ5QQNUQL0UOMEDPECrFDnBA3xAvxQ4KQMCQKiUOSkDQkCyEkDylCypAqpA5pQtqQLqQPGULGkClkDllC1pDPkFPIOeQr5DvkJ+Q35BJyDbmF/IXcQ55CnkNeQl5DtiFvIe8hu5CPkH3IIeQYPgARQoQYIUXIEUqEGqFF6BFGhBlhRdgRToQb4UX4EUFEGBFFxBFJRBqRRRCRRxQRZUQVUUc0EW1EF9FHDBFjxBQxRywRa8RnxCniHPEV8R3xE/EbcYm4Rtwi/iLuEU8RzxEvEa8R24i3iPeIXcRHxD7iEHGMHoAYIUaMkWLkGCVGjdFi9BgjxoyxYuwYJ8aN8WL8mCAmjIli4pgkJo3JYojJY4qYMqaKqWOamDami+ljhpgxZoqZY5aYNeYz5hRzjvmK+Y75ifmNucRcY24xfzH3mKeY55iXmNeYbcxbzHvMLuYjZh9ziDnGD0CCkCAmSAlygpKgJmgJeoKRYCZYCXaCk+AmeAl+QpAQJkQJcUKSkCZkCSTkCUVCmVAl1AlNQpvQJfQJQ8KYMCXMCUvCmvCZcEo4J3wlfCf8JPwmXBKuCbeEv4R7wlPCc8JLwmvCNuEt4T1hl/CRsE84JByTByBFSBFTpBQ5RUlRU7QUPcVIMVOsFDvFSXFTvBQ/JUgJU6KUOCVJSVOyFFLylCKlTKlS6pQmpU3pUvqUIWVMmVLmlCVlTflMOaWcU75SvlN+Un5TLinXlFvKX8o95SnlOeUl5TVlm/KW8p6yS/lI2accUo7pA5AhZIgZUoacoWSoGVqGnmFkmBlWhp3hZLgZXoafEWSEGVFGnJFkpBlZBhl5RpFRZlQZdUaT0WZ0GX3GkDFmTBlzxpKxZnxmnDLOGV8Z3xk/Gb8Zl4xrxi3jL+Oe8ZTxnPGS8ZqxzXjLeM/YZXxk7DMOGcfsAfj/QwTx/1L+WGsfi+FjtXosJ494/wjIj4j5CGmPmPMICo9R+xhWj3b/aJiPlvN4tI+yfxTO4+ofh///5VBACRXU0EALHfQwwAgTzLDACp9wgjN8wTf8wC9c4Ao3+IM7PMEzvMArbOEN3mEHH7CHAxx5AHKEHDFHypFzlBw1R8vRc4wcM8fKsXOcHDfHy/FzgpwwJ8qJc5KcNCfL/x8/zylyypwqp85pctqcLqfPGXLGnClnzlly1pzPnFPOOecr5zvnJ+c355Jzzbnl/OXcc55ynnNecl5ztjlvOe85u5yPnH3OIeeYPwAFQoFYIBXIBUqBWqLine truncated
{"method": "POST", "target": "/alice/tools/issues/new", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 1281", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundarypBVS0yCpwcYAG4g1", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/issues/new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nBuild fails on a clean checkout\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"content\"\r\n\r\nThe build stops after the first step. It works on the second try.\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"files\"\r\n\r\n179d62f5-4ae4-4e5e-9893-269ae399ca12\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"files_fullpath[179d62f5-4ae4-4e5e-9893-269ae399ca12]\"\r\n\r\nscreenshot.png\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"ref\"\r\n\r\n\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"edit_mode\"\r\n\r\ntrue\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"search\"\r\n\r\n\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"label_ids\"\r\n\r\n\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"milestone_id\"\r\n\r\n\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"project_id\"\r\n\r\n\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1\r\nContent-Disposition: form-data; name=\"assignee_ids\"\r\n\r\n\r\n------WebKitFormBoundarypBVS0yCpwcYAG4g1--\r\n"}
{"method": "POST", "target": "/-/fetch-redirect", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 38", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/issues/new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "redirect=%2Falice%2Ftools%2Fissues%2F1"}
{"method": "GET", "target": "/alice/tools/issues/1", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/issues/new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/issues/1/content-history/overview", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/issues/1", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/attachments/179d62f5-4ae4-4e5e-9893-269ae399ca12", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/issues/1", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/issues/1", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "POST", "target": "/alice/tools/issues/1/comments", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 163", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryuLA6czyWTpPLGhxN", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/issues/1", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "------WebKitFormBoundaryuLA6czyWTpPLGhxN\r\nContent-Disposition: form-data; name=\"content\"\r\n\r\nThanks, I can see it too.\r\n------WebKitFormBoundaryuLA6czyWTpPLGhxN--\r\n"}
{"method": "POST", "target": "/-/fetch-redirect", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 55", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/issues/1", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "redirect=%2Falice%2Ftools%2Fissues%2F1%23issuecomment-1"}
{"method": "GET", "target": "/alice/tools/issues/1", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/issues/1", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/releases/new", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "POST", "target": "/alice/tools/releases/attachments", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 227", "Cache-Control: no-cache", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: application/json", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundarygH3DxdI69SJCwZ2b", "Origin: http://example.com", "Referer: http://example.com/alice/tools/releases/new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "------WebKitFormBoundarygH3DxdI69SJCwZ2b\r\nContent-Disposition: form-data; name=\"file\"; filename=\"tools-linux-amd64.tar.gz\"\r\nContent-Type: application/gzip\r\n\r\neJwryc/PKVZIysxLLKosGQFsAIkDYLk=\r\n------WebKitFormBoundarygH3DxdI69SJCwZ2b--\r\n"}
{"method": "POST", "target": "/alice/tools/releases/new", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 261", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/releases/new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "tag_name=v1.1.0&tag_target=main&title=Version+1.1.0&content=Faster+builds.&attachment-template-new-name=&attachment-template-new-exturl=&files=a637a7ba-b419-4ec3-8c33-1c119e93bc14&files_fullpath%5Ba637a7ba-b419-4ec3-8c33-1c119e93bc14%5D=tools-linux-amd64.tar.gz"}
{"method": "GET", "target": "/alice/tools/releases", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/releases/new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/wiki/?action=_new", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "POST", "target": "/alice/tools/wiki/?action=_new", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 78", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/wiki/?action=_new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "title=Getting+started&content=Clone+the+repository+and+run+the+build.&message="}
{"method": "GET", "target": "/alice/tools/wiki/Getting-started", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/wiki/?action=_new", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/wiki/Getting-started", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/wiki/?action=_pages", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/user/settings", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "POST", "target": "/user/settings/avatar", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 4377", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryR5Mv0yttLbvaY13B", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/settings", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "------WebKitFormBoundaryR5Mv0yttLbvaY13B\r\nContent-Disposition: form-data; name=\"avatar\"; filename=\"avatar.png\"\r\nContent-Type: image/png\r\n\r\niVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAQJ0lEQVR4nBXXrbpFUBSG0V0URVEURVEURVEURVEURVEURXmLoiiKoiiKoiiKoiiKoiiKoriJ47iC8Vjz55u/3w/hh/hD+iH/UH6oP7Qf+g/jh/nD+mH/cH64P7wf/o/gR/gj+hH/SH6kP7If/Mh/FD/KH9WP+kfzo/3R/eh/DD/GH9OP+cfyY/2x/dh/HD/OH9eP+8fz4/3x+wkIAqKAJCALKAKqgCagCxgCpoAlYAs4Aq6AJ+ALBAKhQCQQCyQCqUAmgEAuUAiUApVALdAItAKdQC8wCIwCk8AssAisApvALnAInAKXwC3wCLzCBxIRREQRSUQWUURUEU1EFzFETBFLxBZxRFwRT8QXCURCkUgkFklEUpFMBJFcpBApRSqRWqQRaUU6kV5kEBlFJpFZZBFZRTaRXeQQOUUukVvkEXnFDyQhSIgSkoQsoUioEpqELmFImBKWhC3hSLgSnoQvEUiEEpFELJFIpBKZBBK5RCFRSlQStUQj0Up0Er3EIDFKTBKzxCKxSmwSu8QhcUpcErfEI/FKH0hGkBFlJBlZRpFRZTQZXcaQMWUsGVvGkXFlPBlfJpAJZSKZWCaRSWUyGWRymUKmlKlkaplGppXpZHqZQWaUmWRmmUVmldlkdplD5pS5ZG6ZR+aVP5CCoCAqSAqygqKgKmgKuoKhYCpYCraCo+AqeAq+QqAQKkQKsUKikCpkCijkCoVCqVAp1AqNQqvQKfQKg8KoMCnMCovCqrAp7AqHwqlwKdwKj8KrfCAVQUVUkVRkFUVFVdFUdBVDxVSxVGwVR8VV8VR8lUAlVIlUYpVEJVXJVFDJVQqVUqVSqVUalValU+lVBpVRZVKZVRaVVWVT2VUOlVPlUrlVHpVX/UAagoaoIWnIGoqGqqFp6BqGhqlhadgajoar4Wn4GoFGqBFpxBqJRqqRaaCRaxQapUalUWs0Gq1Gp9FrDBqjxqQxaywaq8amsWscGqfGpXFrPBqv9oF0BB1RR9KRdRQdVUfT0XUMHVPH0rF1HB1Xx9PxdQKdUCfSiXUSnVQn00En1yl0Sp1Kp9ZpdFqdTqfXGXRGnUln1ll0Vp1NZ9c5dE6dS+fWeXRe/QMZCAaigWQgGygGqoFmoBsYBqaBZWAbOAaugWfgGwQGoUFkEBskBqlBZoBBblAYlAaVQW3QGLQGnUFvMBiMBpPBbLAYrAabwW5wGJwGl8Ft8Bi8xgcyEUxEE8lENlFMVBPNRDcxTEwTy8Q2cUxcE8/ENwlMQpPIJDZJTFKTzAST3KQwKU0qk9qkMWlNOpPeZDAZTSaT2WQxWU02k93kMDlNLpPb5DF5zQ9kIViIFpKFbKFYqBaahW5hWJgWloVt4Vi4Fp6FbxFYhBaRRWyRWKQWmQUWuUVhUVpUFrVFY9FadBa9xWAxWkwWs8VisVpsFrvFYXFaXBa3xWPxWh/IRrARbSQb2UaxUW00G93GsDFtLBvbxrFxbTwb3yawCW0im9gmsUltMhtscpvCprSpbGqbxqa16Wx6m8FmtJlsZpvFZrXZbHabw+a0uWxum8fmtT+Qg+AgOkgOsoPioDpoDrqD4WA6WA62g+PgOngOvkPgEDpEDrFD4pA6ZA445A6FQ+lQOdQOjUPr0Dn0DoPD6DA5zA6Lw+qwOewOh8PpcDncDo/D63wgF8FFdJFcZBfFRXXRXHQXw8V0sVxsF8fFdfFcfJfAJXSJXGKXxCV1yVxwyV0Kl9KlcqldGpfWpXPpXQaX0WVymV0Wl9Vlc9ldDpfT5XK5XR6X1/1AHoKH6CF5yB6Kh+qheegehofpYXnYHo6H6+F5+B6BR+gRecQeiUfqkXngkXsUHqVH5VF7NB6tR+fRewweo8fkMXssHqvH5rF7HB6nx+Vxezwer/eBfAQf0UfykX0UH9VH89F9DB/Tx/KxfRwf18fz8X0Cn9An8ol9Ep/UJ/PBJ/cpfEqfyqf2aXxan86n9xl8Rp/JZ/ZZfFafzWf3OXxOn8vn9nl8Xv8DBQgBYoAUIAcoAWqAFqAHGAFmgBVgBzgBboAX4AcEAWFAFBAHJAFpQBZAQB5QBJQBVUAd0AS0AV1AHzAEjAFTwBywBKwBW8AecAScAVfAHfAEvMEHChFCxBApRA5RQtQQLUQPMULMECvEDnFC3BAvxA8JQsKQKCQOSULSkCyEkDykCClDqpA6pAlpQ7qQPmQIGUOmkDlkCVlDtpA95Ag5Q66QO+QJecMPFCFEiBFShByhRKgRWoQeYUSYEVaEHeFEuBFehB8RRIQRUUQckUSkEVkEEXlEEVFGVBF1RBPRRnQRfcQQMUZMEXPEErFGbBF7xBFxRlwRd8QT8UYfKEaIEWOkGDlGiVFjtBg9xogxY6wYO8aJcWO8GD8miAljopg4JolJY7IYYvKYIqaMqWLqmCamjeli+pghZoyZYuaYJWaN2WL2mCPmjLli7pgn5o0/UIKQICZICXKCkqAmaAl6gpFgJlgJdoKT4CZ4CX5CkBAmRAlxQpKQJmQJJOQJRUKZUCXUCU1Cm9Al9AlDwpgwJcwJS8KasCXsCUfCmXAl3AlPwpt8oBQhRUyRUuQUJUVN0VL0FCPFTLFS7BQnxU3xUvyUICVMiVLilCQlTclSSMlTipQypUqpU5qUNqVL6VOGlDFlSplTlpQ1ZUvZU46UM+VKuVOelDf9QBlChpghZcgZSoaaoWXoGUaGmWFl2BlOhpvhZfgZQUaYEWXEGUlGmpFlkJFnFBllRpVRZzQZbUaX0WcMGWPGlDFnLBlrxpaxZxwZZ8aVcWc8GW/2gf4PmO9k+EL6F4u/IPpFvy9sffHmCxTfCv+W5remvsXwjeJv+H3j5mvwr6W+Iv7K5nuo79d8mP8vhwJKqKCGBlrooIcBRphghgVW2GCHA0644IYH3u+8++UIOWKOlCPnKDlqjpaj5xg5Zo6VY+c4OW6Ol+PnBDlhTpQT5yQ5aU6W/3PynCKnzKly6pwmp83pcvqcIWfMmXLmnCVnzdly9pwj58y5cu6cJ+fNP1CBUCAWSAVygVKgFmgFeoFRYBZYBXaBU+AWeAV+QVAQFkQFcUFSkBZkxf/PyQuKgrKgKqgLmoK2oCvoC4aCsWAqmAuWgrVgK9gLjoKz4Cq4C56Ct/hAJUKJWCKVyCVKiVqileglRolZYpXYJU6JW+KV+CVBSVgSlcQlSUlakpX/T5WXFCVlSVVSlzQlbUlX0pcMJWPJVDKXLCVryVaylxwlZ8lVcpc8JW/5gSqECrFCqpArlAq1QqvQK4wKs8KqsCucCrfCq/ArgoqwIqqIK5KKtCKr/gsnrygqyoqqoq5oKtqKrqKvGCrGiqlirlgq1oqtYq84Ks6Kq+KueCre6gPVCDVijVQj1yg1ao1Wo9cYNWaNVWPXODVujVfj1wQ1YU1UE9ckNWlNVv+XcV5T1JQ1VU1d09S0NV1NXzPUjDVTzVyz1Kw1W81ec9ScNVfNXfPUvPUHahAaxAapQW5QGtQGrUFvMBrMBqvBbnAa3AavwW8IGsKGqCFuSBrShqz5b6q8oWgoG6qGuqFpaBu6hr5haBgbpoa5YWlYG7aGveFoOBuuhrvhaXibD9QitIgtUovcorSoLVqL3mK0mC1Wi93itLgtXovfErSELVFL3JK0pC1Z+9/ieUvRUrZULXVL09K2dC19y9Aytkwtc8vSsrZsLXvL0XK2XC13y9Pyth+oQ+gQO6QOuUPpUDu0Dr3D6DA7rA67w+lwO7wOvyPoCDuijrgj6Ug7su5/4OQdRUfZUXXUHU1H29F19B1Dx9gxdcwdS8fasXXsHUfH2XF13B1Px9t9oB6hR+yReuQepUft0Xr0HqPH7LF67B6nx+3xevyeoCfsiXrinqQn7cn6//GX9xQ9ZU/VU/c0PW1P19P3DD1jz9Qz9yw9a8/Ws/ccPWfP1XP3PD1v/4EGhAFxQBqQB5QBdUAb0AeMAXPAGrAHnAF3wBvwB4KBcCAaiAeSgXQgG/6HcT5QDJQD1UA90Ay0A91APzAMjAPTwDywDKwD28A+cAycA9fAPfAMvMMHGhFGxBFpRB5RRtQRbUQfMUbMEWvEHnFG3BFvxLine truncated
{"method": "GET", "target": "/user/settings", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/settings", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US; flash=success%3DYour%2Bavatar%2Bhas%2Bbeen%2Bupdated."], "body": ""}
{"method": "GET", "target": "/avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/user/settings", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/settings", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "POST", "target": "/alice/tools/settings/avatar", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 4377", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundarykD1bO7T1SED3rJ5v", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/settings", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": "------WebKitFormBoundarykD1bO7T1SED3rJ5v\r\nContent-Disposition: form-data; name=\"avatar\"; filename=\"avatar.png\"\r\nContent-Type: image/png\r\n\r\niVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAQJ0lEQVR4nBXXrbpFUBSG0V0URVEURVEURVEURVEURVEURXmLoiiKoiiKoiiKoiiKoiiKoriJ47iC8Vjz55u/3w/hh/hD+iH/UH6oP7Qf+g/jh/nD+mH/cH64P7wf/o/gR/gj+hH/SH6kP7If/Mh/FD/KH9WP+kfzo/3R/eh/DD/GH9OP+cfyY/2x/dh/HD/OH9eP+8fz4/3x+wkIAqKAJCALKAKqgCagCxgCpoAlYAs4Aq6AJ+ALBAKhQCQQCyQCqUAmgEAuUAiUApVALdAItAKdQC8wCIwCk8AssAisApvALnAInAKXwC3wCLzCBxIRREQRSUQWUURUEU1EFzFETBFLxBZxRFwRT8QXCURCkUgkFklEUpFMBJFcpBApRSqRWqQRaUU6kV5kEBlFJpFZZBFZRTaRXeQQOUUukVvkEXnFDyQhSIgSkoQsoUioEpqELmFImBKWhC3hSLgSnoQvEUiEEpFELJFIpBKZBBK5RCFRSlQStUQj0Up0Er3EIDFKTBKzxCKxSmwSu8QhcUpcErfEI/FKH0hGkBFlJBlZRpFRZTQZXcaQMWUsGVvGkXFlPBlfJpAJZSKZWCaRSWUyGWRymUKmlKlkaplGppXpZHqZQWaUmWRmmUVmldlkdplD5pS5ZG6ZR+aVP5CCoCAqSAqygqKgKmgKuoKhYCpYCraCo+AqeAq+QqAQKkQKsUKikCpkCijkCoVCqVAp1AqNQqvQKfQKg8KoMCnMCovCqrAp7AqHwqlwKdwKj8KrfCAVQUVUkVRkFUVFVdFUdBVDxVSxVGwVR8VV8VR8lUAlVIlUYpVEJVXJVFDJVQqVUqVSqVUalValU+lVBpVRZVKZVRaVVWVT2VUOlVPlUrlVHpVX/UAagoaoIWnIGoqGqqFp6BqGhqlhadgajoar4Wn4GoFGqBFpxBqJRqqRaaCRaxQapUalUWs0Gq1Gp9FrDBqjxqQxaywaq8amsWscGqfGpXFrPBqv9oF0BB1RR9KRdRQdVUfT0XUMHVPH0rF1HB1Xx9PxdQKdUCfSiXUSnVQn00En1yl0Sp1Kp9ZpdFqdTqfXGXRGnUln1ll0Vp1NZ9c5dE6dS+fWeXRe/QMZCAaigWQgGygGqoFmoBsYBqaBZWAbOAaugWfgGwQGoUFkEBskBqlBZoBBblAYlAaVQW3QGLQGnUFvMBiMBpPBbLAYrAabwW5wGJwGl8Ft8Bi8xgcyEUxEE8lENlFMVBPNRDcxTEwTy8Q2cUxcE8/ENwlMQpPIJDZJTFKTzAST3KQwKU0qk9qkMWlNOpPeZDAZTSaT2WQxWU02k93kMDlNLpPb5DF5zQ9kIViIFpKFbKFYqBaahW5hWJgWloVt4Vi4Fp6FbxFYhBaRRWyRWKQWmQUWuUVhUVpUFrVFY9FadBa9xWAxWkwWs8VisVpsFrvFYXFaXBa3xWPxWh/IRrARbSQb2UaxUW00G93GsDFtLBvbxrFxbTwb3yawCW0im9gmsUltMhtscpvCprSpbGqbxqa16Wx6m8FmtJlsZpvFZrXZbHabw+a0uWxum8fmtT+Qg+AgOkgOsoPioDpoDrqD4WA6WA62g+PgOngOvkPgEDpEDrFD4pA6ZA445A6FQ+lQOdQOjUPr0Dn0DoPD6DA5zA6Lw+qwOewOh8PpcDncDo/D63wgF8FFdJFcZBfFRXXRXHQXw8V0sVxsF8fFdfFcfJfAJXSJXGKXxCV1yVxwyV0Kl9KlcqldGpfWpXPpXQaX0WVymV0Wl9Vlc9ldDpfT5XK5XR6X1/1AHoKH6CF5yB6Kh+qheegehofpYXnYHo6H6+F5+B6BR+gRecQeiUfqkXngkXsUHqVH5VF7NB6tR+fRewweo8fkMXssHqvH5rF7HB6nx+Vxezwer/eBfAQf0UfykX0UH9VH89F9DB/Tx/KxfRwf18fz8X0Cn9An8ol9Ep/UJ/PBJ/cpfEqfyqf2aXxan86n9xl8Rp/JZ/ZZfFafzWf3OXxOn8vn9nl8Xv8DBQgBYoAUIAcoAWqAFqAHGAFmgBVgBzgBboAX4AcEAWFAFBAHJAFpQBZAQB5QBJQBVUAd0AS0AV1AHzAEjAFTwBywBKwBW8AecAScAVfAHfAEvMEHChFCxBApRA5RQtQQLUQPMULMECvEDnFC3BAvxA8JQsKQKCQOSULSkCyEkDykCClDqpA6pAlpQ7qQPmQIGUOmkDlkCVlDtpA95Ag5Q66QO+QJecMPFCFEiBFShByhRKgRWoQeYUSYEVaEHeFEuBFehB8RRIQRUUQckUSkEVkEEXlEEVFGVBF1RBPRRnQRfcQQMUZMEXPEErFGbBF7xBFxRlwRd8QT8UYfKEaIEWOkGDlGiVFjtBg9xogxY6wYO8aJcWO8GD8miAljopg4JolJY7IYYvKYIqaMqWLqmCamjeli+pghZoyZYuaYJWaN2WL2mCPmjLli7pgn5o0/UIKQICZICXKCkqAmaAl6gpFgJlgJdoKT4CZ4CX5CkBAmRAlxQpKQJmQJJOQJRUKZUCXUCU1Cm9Al9AlDwpgwJcwJS8KasCXsCUfCmXAl3AlPwpt8oBQhRUyRUuQUJUVN0VL0FCPFTLFS7BQnxU3xUvyUICVMiVLilCQlTclSSMlTipQypUqpU5qUNqVL6VOGlDFlSplTlpQ1ZUvZU46UM+VKuVOelDf9QBlChpghZcgZSoaaoWXoGUaGmWFl2BlOhpvhZfgZQUaYEWXEGUlGmpFlkJFnFBllRpVRZzQZbUaX0WcMGWPGlDFnLBlrxpaxZxwZZ8aVcWc8GW/2gf4PmO9k+EL6F4u/IPpFvy9sffHmCxTfCv+W5remvsXwjeJv+H3j5mvwr6W+Iv7K5nuo79d8mP8vhwJKqKCGBlrooIcBRphghgVW2GCHA0644IYH3u+8++UIOWKOlCPnKDlqjpaj5xg5Zo6VY+c4OW6Ol+PnBDlhTpQT5yQ5aU6W/3PynCKnzKly6pwmp83pcvqcIWfMmXLmnCVnzdly9pwj58y5cu6cJ+fNP1CBUCAWSAVygVKgFmgFeoFRYBZYBXaBU+AWeAV+QVAQFkQFcUFSkBZkxf/PyQuKgrKgKqgLmoK2oCvoC4aCsWAqmAuWgrVgK9gLjoKz4Cq4C56Ct/hAJUKJWCKVyCVKiVqileglRolZYpXYJU6JW+KV+CVBSVgSlcQlSUlakpX/T5WXFCVlSVVSlzQlbUlX0pcMJWPJVDKXLCVryVaylxwlZ8lVcpc8JW/5gSqECrFCqpArlAq1QqvQK4wKs8KqsCucCrfCq/ArgoqwIqqIK5KKtCKr/gsnrygqyoqqoq5oKtqKrqKvGCrGiqlirlgq1oqtYq84Ks6Kq+KueCre6gPVCDVijVQj1yg1ao1Wo9cYNWaNVWPXODVujVfj1wQ1YU1UE9ckNWlNVv+XcV5T1JQ1VU1d09S0NV1NXzPUjDVTzVyz1Kw1W81ec9ScNVfNXfPUvPUHahAaxAapQW5QGtQGrUFvMBrMBqvBbnAa3AavwW8IGsKGqCFuSBrShqz5b6q8oWgoG6qGuqFpaBu6hr5haBgbpoa5YWlYG7aGveFoOBuuhrvhaXibD9QitIgtUovcorSoLVqL3mK0mC1Wi93itLgtXovfErSELVFL3JK0pC1Z+9/ieUvRUrZULXVL09K2dC19y9Aytkwtc8vSsrZsLXvL0XK2XC13y9Pyth+oQ+gQO6QOuUPpUDu0Dr3D6DA7rA67w+lwO7wOvyPoCDuijrgj6Ug7su5/4OQdRUfZUXXUHU1H29F19B1Dx9gxdcwdS8fasXXsHUfH2XF13B1Px9t9oB6hR+yReuQepUft0Xr0HqPH7LF67B6nx+3xevyeoCfsiXrinqQn7cn6//GX9xQ9ZU/VU/c0PW1P19P3DD1jz9Qz9yw9a8/Ws/ccPWfP1XP3PD1v/4EGhAFxQBqQB5QBdUAb0AeMAXPAGrAHnAF3wBvwB4KBcCAaiAeSgXQgG/6HcT5QDJQD1UA90Ay0A91APzAMjAPTwDywDKwD28A+cAycA9fAPfAMvMMHGhFGxBFpRB5RRtQRbUQfMUbLine truncated
{"method": "GET", "target": "/alice/tools/settings", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/settings", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US; flash=success%3DThe%2Brepository%2Bavatar%2Bhas%2Bbeen%2Bupdated."], "body": ""}
{"method": "GET", "target": "/repo-avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/settings", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/settings/collaboration", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/user/search_candidates?q=bob", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/javascript, */*; q=0.01", "X-Requested-With: XMLHttpRequest", "Referer: http://example.com/alice/tools/settings/collaboration", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/avatars/4b9bb80620f03eb3719e0a061c14283d", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/settings/collaboration", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/user/search_candidates?q=bob", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/user/settings/applications", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/admin", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/admin/users", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/admin/users?q=bob", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/admin/emails", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/admin/emails?q=example.com", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/admin/orgs", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/admin/repos", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/admin/repos?q=python3", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/admin/packages", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/admin/auths", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/admin/config", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/admin/config/settings", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/admin/monitor/stats", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/admin/monitor/queue", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/admin/monitor/cron", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/admin/hooks", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/img/gogs.ico", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/hooks", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/img/feishu.png", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/hooks", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/img/slack.png", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/hooks", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/img/discord.png", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/hooks", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/img/telegram.png", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/hooks", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/img/dingtalk.ico", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/hooks", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/img/msteams.png", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/hooks", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/img/wechatwork.png", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/hooks", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/img/packagist.png", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/hooks", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/img/sourcehut.svg", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/hooks", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/admin/notices", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/admin/users/2", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/avatars/4b9bb80620f03eb3719e0a061c14283d?size=128", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/users/2", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/admin/users/new", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/user/settings/account", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/user/settings/appearance", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/user/settings/security", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/user/settings/keys", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/user/settings/packages", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/notifications", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice?tab=activity", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice?tab=stars", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/org/acme/dashboard", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/acme", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/avatars/53bce4f1dfa0fe8e7ca126f91b35d3a6", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/acme", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba?size=128", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/acme", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/settings/branches", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/settings/hooks", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/settings/units", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/repo/migrate", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/-/packages", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=d65379616ffd0971; lang=en-US"], "body": ""}
{"method": "GET", "target": "/avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: session=b7d8a1e4a0871671; lang=en-US"], "body": ""}
{"method": "GET", "target": "/repo-avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/compare/main...python3-migration", "Accept-Encoding: gzip, deflate", "Cookie: session=b7d8a1e4a0871671; lang=en-US"], "body": ""}
{"method": "GET", "target": "/user/settings/applications/tokens/new", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=890deb73026afc5f; lang=en-US"], "body": ""}
{"method": "GET", "target": "/avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba?size=128", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/compare/main...python3-migration", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
{"method": "POST", "target": "/alice/tools/compare/main...python3-migration", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 764", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryaGATihSdXZHYz0ae", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/compare/main...python3-migration", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": "------WebKitFormBoundaryaGATihSdXZHYz0ae\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nMove to python3\r\n------WebKitFormBoundaryaGATihSdXZHYz0ae\r\nContent-Disposition: form-data; name=\"content\"\r\n\r\nPorts the scripts.\r\n------WebKitFormBoundaryaGATihSdXZHYz0ae\r\nContent-Disposition: form-data; name=\"label_ids\"\r\n\r\n\r\n------WebKitFormBoundaryaGATihSdXZHYz0ae\r\nContent-Disposition: form-data; name=\"milestone_id\"\r\n\r\n\r\n------WebKitFormBoundaryaGATihSdXZHYz0ae\r\nContent-Disposition: form-data; name=\"project_id\"\r\n\r\n\r\n------WebKitFormBoundaryaGATihSdXZHYz0ae\r\nContent-Disposition: form-data; name=\"assignee_ids\"\r\n\r\n\r\n------WebKitFormBoundaryaGATihSdXZHYz0ae\r\nContent-Disposition: form-data; name=\"redirect_after_creation\"\r\n\r\n\r\n------WebKitFormBoundaryaGATihSdXZHYz0ae--\r\n"}
{"method": "POST", "target": "/-/fetch-redirect", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 37", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/compare/main...python3-migration", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": "redirect=%2Falice%2Ftools%2Fpulls%2F2"}
{"method": "GET", "target": "/alice/tools/pulls/2", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/compare/main...python3-migration", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/issues/2/content-history/overview", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/pulls/2", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/css/pull-request-merge-form.08cfd3a0.css", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/alice/tools/pulls/2", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/pull-request-merge-form.8413f096.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/pulls/2", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/pulls", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/pulls/2", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/pulls/2/commits", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/pulls/2/files", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/css/diff-commit-selector.17df8f29.css", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/alice/tools/pulls/2/files", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/diff-commit-selector.302a470f.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/pulls/2/files", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
{"method": "POST", "target": "/user/settings", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 142", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/settings", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": "name=alice&full_name=Alice+Example&pronouns=&biography=Writes+build+tools.&website=https%3A%2F%2Fexample.com&location=Springfield&visibility=0"}
{"method": "POST", "target": "/admin/users/new", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 185", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/users/new", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": "_autofill_dummy_username=&_autofill_dummy_password=&login_type=0-0&visibility=0&login_name=&user_name=carol&email=carol%40example.com&password=Qe5-tVb7-Mn3c-Kj8y&must_change_password=on"}
{"method": "GET", "target": "/admin/users/4", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/users/new", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US; flash=success%3DThe%2Buser%2Baccount%2B%2522carol%2522%2Bhas%2Bbeen%2Bcreated."], "body": ""}
{"method": "GET", "target": "/avatars/d4766e3f21c67b7c786f012d910fa54f?size=128", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/users/4", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
{"method": "GET", "target": "/admin/users/2/edit", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": ""}
{"method": "POST", "target": "/admin/users/2/edit", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 265", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/users/2/edit", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US"], "body": "_autofill_dummy_username=&_autofill_dummy_password=&user_name=bob&login_type=0-0&visibility=0&login_name=&full_name=Bob+Example&pronouns=&email=bob%40example.com&password=&language=en-US&website=&location=&max_repo_creation=-1&active=on&allow_create_organization=on"}
{"method": "GET", "target": "/admin/users/2", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/users/2/edit", "Accept-Encoding: gzip, deflate", "Cookie: session=9f3cc5769e87c814; lang=en-US; flash=success%3DThe%2Buser%2Baccount%2Bhas%2Bbeen%2Bupdated."], "body": ""}
{"method": "POST", "target": "/user/settings/applications/tokens/new", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 241", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/settings/applications/tokens/new", "Accept-Encoding: gzip, deflate", "Cookie: session=34fdf904aa90270d; lang=en-US"], "body": "name=build-token&repo_search=&page=1&scope=write%3Aactivitypub&scope=write%3Aadmin&scope=write%3Aissue&scope=write%3Amisc&scope=write%3Anotification&scope=write%3Aorganization&scope=write%3Apackage&scope=write%3Arepository&scope=write%3Auser"}
{"method": "POST", "target": "/user/settings/applications/tokens/new", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 254", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/settings/applications/tokens/new", "Accept-Encoding: gzip, deflate", "Cookie: session=8ed5d13514629cf4; lang=en-US"], "body": "name=build-token&resource=all&repo_search=&page=1&scope=write%3Aactivitypub&scope=write%3Aadmin&scope=write%3Aissue&scope=write%3Amisc&scope=write%3Anotification&scope=write%3Aorganization&scope=write%3Apackage&scope=write%3Arepository&scope=write%3Auser"}
{"method": "GET", "target": "/user/settings/applications", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/settings/applications/tokens/new", "Accept-Encoding: gzip, deflate", "Cookie: session=8ed5d13514629cf4; lang=en-US; flash=info%3D518efde244d57864ef12487cdec73aa828e5deaf%26success%3DYour%2Bnew%2Btoken%2Bhas%2Bbeen%2Bgenerated.%2BCopy%2Bit%2Bnow%2Bas%2Bit%2Bwill%2Bnot%2Bbe%2Bshown%2Bagain."], "body": ""}
{"method": "GET", "target": "/api/v1/version", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/user", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/settings/api", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/search?q=python3&limit=10", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/search?q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/search?q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/users/search?q=bob", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/users/search?q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/issues/search?q=ls%20-la&type=issues", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/issues/search?q=build", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/issues?state=open&q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/issues?state=all&q=docker-compose.yml", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/branches", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/branches/python3-migration", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/tags", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/contents?ref=main", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/contents/.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/contents/docker-compose.yml", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/contents/scripts/install-script.sh?ref=main", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/raw/bin/docker-entrypoint", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/media/src/process.env.d.ts", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/commits?path=scripts/install-script.sh&limit=5", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/commits?sha=python3-migration", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/git/trees/main", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/releases", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/pulls", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/labels", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/milestones", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/collaborators", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/repos/alice/tools/hooks", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/admin/users", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/admin/orgs", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/orgs/acme", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/users/alice/repos", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/notifications", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "POST", "target": "/api/v1/repos/alice/tools/labels", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 68"], "body": "{\"name\":\"bug\",\"color\":\"#ee0701\",\"description\":\"Something is broken\"}"}
{"method": "POST", "target": "/api/v1/repos/alice/tools/issues", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 83"], "body": "{\"title\":\"Document the python3 setup\",\"body\":\"Say which version the scripts need.\"}"}
{"method": "POST", "target": "/api/v1/repos/alice/tools/issues/3/comments", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 40"], "body": "{\"body\":\"The setup notes cover it now.\"}"}
{"method": "PATCH", "target": "/api/v1/repos/alice/tools/issues/3", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 18"], "body": "{\"state\":\"closed\"}"}
{"method": "POST", "target": "/api/v1/repos/alice/tools/issues/3/assets", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Length: 246", "Content-Type: multipart/form-data; boundary=------------------------c8PTgaGzJM0bzMXgzs4jFA"], "body": "--------------------------c8PTgaGzJM0bzMXgzs4jFA\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"notes.txt\"\r\nContent-Type: text/plain\r\n\r\nSteps to reproduce: run the build twice.\n\r\n--------------------------c8PTgaGzJM0bzMXgzs4jFA--\r\n"}
{"method": "POST", "target": "/api/v1/repos/alice/tools/issues/3/assets?name=docker-compose.yml", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Length: 246", "Content-Type: multipart/form-data; boundary=------------------------8vBtFvaNGea9428ByqN6zh"], "body": "--------------------------8vBtFvaNGea9428ByqN6zh\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"notes.txt\"\r\nContent-Type: text/plain\r\n\r\nSteps to reproduce: run the build twice.\n\r\n--------------------------8vBtFvaNGea9428ByqN6zh--\r\n"}
{"method": "POST", "target": "/api/v1/repos/alice/tools/releases", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 69"], "body": "{\"tag_name\":\"v1.2.0\",\"name\":\"Version 1.2.0\",\"body\":\"Smaller images.\"}"}
{"method": "GET", "target": "/api/v1/repos/alice/tools/releases/tags/v1.2.0", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "POST", "target": "/api/v1/repos/alice/tools/releases/3/assets?name=install-script.sh", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Length: 246", "Content-Type: multipart/form-data; boundary=------------------------a8C2mRAVkCsfebNVoTuH9d"], "body": "--------------------------a8C2mRAVkCsfebNVoTuH9d\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"notes.txt\"\r\nContent-Type: text/plain\r\n\r\nSteps to reproduce: run the build twice.\n\r\n--------------------------a8C2mRAVkCsfebNVoTuH9d--\r\n"}
{"method": "POST", "target": "/api/v1/repos/alice/tools/releases/3/assets", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Length: 246", "Content-Type: multipart/form-data; boundary=------------------------bwR0eGsF72IMLlKpnzjarK"], "body": "--------------------------bwR0eGsF72IMLlKpnzjarK\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"notes.txt\"\r\nContent-Type: text/plain\r\n\r\nSteps to reproduce: run the build twice.\n\r\n--------------------------bwR0eGsF72IMLlKpnzjarK--\r\n"}
{"method": "POST", "target": "/api/v1/repos/alice/tools/contents/scripts/powershell-build.ps1", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 100"], "body": "{\"content\":\"V3JpdGUtSG9zdCAiYnVpbGQiCg==\",\"message\":\"Add a PowerShell build script\",\"branch\":\"main\"}"}
{"method": "POST", "target": "/api/v1/repos/alice/tools/branches", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 59"], "body": "{\"new_branch_name\":\"python3-docs\",\"old_branch_name\":\"main\"}"}
{"method": "POST", "target": "/api/v1/repos/alice/tools/pulls", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 96"], "body": "{\"title\":\"Add Dockerfile\",\"head\":\"feat/docker-support\",\"base\":\"main\",\"body\":\"Builds the image.\"}"}
{"method": "PATCH", "target": "/api/v1/repos/alice/tools", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 87"], "body": "{\"description\":\"Build scripts, notes and a Dockerfile\",\"website\":\"https://example.com\"}"}
{"method": "POST", "target": "/api/v1/repos/alice/tools/forks", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 23"], "body": "{\"organization\":\"acme\"}"}
{"method": "POST", "target": "/api/v1/admin/users", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 107"], "body": "{\"username\":\"dave\",\"email\":\"dave@example.com\",\"password\":\"Lp6-xRw4-Bq9z-Hs3d\",\"must_change_password\":false}"}
{"method": "PATCH", "target": "/api/v1/admin/users/dave", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf", "Content-Type: application/json", "Content-Length: 80"], "body": "{\"login_name\":\"dave\",\"full_name\":\"Dave Example\",\"password\":\"Yt2-cNm8-Fg5k-Wa7e\"}"}
{"method": "DELETE", "target": "/api/v1/repos/alice/tools/branches/python3-docs", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/packages/alice/generic/tools/1.2.0/tools-linux-amd64.tar.gz", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/packages/alice?q=tools", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "GET", "target": "/api/v1/packages/alice?type=generic&q=install-script", "headers": ["Host: example.com", "User-Agent: curl/8.17.0", "Accept: */*", "Authorization: token 518efde244d57864ef12487cdec73aa828e5deaf"], "body": ""}
{"method": "PUT", "target": "/api/packages/alice/generic/tools/1.2.0/tools-linux-amd64.tar.gz", "headers": ["Host: example.com", "Authorization: Basic YWxpY2U6NTE4ZWZkZTI0NGQ1Nzg2NGVmMTI0ODdjZGVjNzNhYTgyOGU1ZGVhZg==", "User-Agent: curl/8.17.0", "Accept: */*", "Content-Length: 41"], "body": "Steps to reproduce: run the build twice.\n"}
{"method": "PUT", "target": "/api/packages/alice/generic/install-script/1.0.0/install-script.sh", "headers": ["Host: example.com", "Authorization: Basic YWxpY2U6NTE4ZWZkZTI0NGQ1Nzg2NGVmMTI0ODdjZGVjNzNhYTgyOGU1ZGVhZg==", "User-Agent: curl/8.17.0", "Accept: */*", "Content-Length: 41"], "body": "Steps to reproduce: run the build twice.\n"}
{"method": "GET", "target": "/v2/", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Accept-Encoding: gzip"], "body": ""}
{"method": "GET", "target": "/v2/token?scope=repository%3Aalice%2Fpython3-app%3Apush%2Cpull&service=container_registry", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Basic YWxpY2U6NTE4ZWZkZTI0NGQ1Nzg2NGVmMTI0ODdjZGVjNzNhYTgyOGU1ZGVhZg==", "Accept-Encoding: gzip"], "body": ""}
{"method": "HEAD", "target": "/v2/alice/python3-app/manifests/1.0", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Accept: application/vnd.docker.distribution.manifest.v1+json,application/vnd.docker.distribution.manifest.v1+prettyjws,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.index.v1+json", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0"], "body": ""}
{"method": "HEAD", "target": "/v2/alice/python3-app/blobs/sha256:1854e725e9328a03d70efaa86ee7b8b824558607f656efcfc81519a9c4621068", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0"], "body": ""}
{"method": "POST", "target": "/v2/alice/python3-app/blobs/uploads/", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Content-Length: 0", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Content-Type: application/json", "Accept-Encoding: gzip"], "body": ""}
{"method": "HEAD", "target": "/v2/alice/python3-app/blobs/sha256:f4c0b6312e4a446199d113e4858a0fd16aa27ea9edd150ba18dd9617cc44442d", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0"], "body": ""}
{"method": "PATCH", "target": "/v2/alice/python3-app/blobs/uploads/iljdbd0gxw2zdibpfrajzu", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Content-Length: 144", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Content-Type: application/octet-stream", "Accept-Encoding: gzip"], "body": "H4sIAAAAAAAE/+zRQQoCIRTG8bfuFJ6g+Wu+8TwughYDymTQ8YOaNhK0More+y1E3ej35VonGTwASVV4TL8CiNeQFB9jQvAcohenQ1+1uZxbXp2TtZS2Hb307v5H5Vqn03FZyr5dR/0PYI6x7/25B+au/xDw4vhEpn/e/7373fiYjTHGfJfbAH2FcNwACgAA"}
{"method": "PUT", "target": "/v2/alice/python3-app/blobs/uploads/iljdbd0gxw2zdibpfrajzu?digest=sha256%3A1854e725e9328a03d70efaa86ee7b8b824558607f656efcfc81519a9c4621068", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Content-Length: 0", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Content-Type: application/octet-stream", "Accept-Encoding: gzip"], "body": ""}
{"method": "PATCH", "target": "/v2/alice/python3-app/blobs/uploads/1eu6yk4t4l8lmbsedmyw9l", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Content-Length: 233", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Content-Type: application/octet-stream", "Accept-Encoding: gzip"], "body": "{\"architecture\":\"\",\"created\":\"0001-01-01T00:00:00Z\",\"history\":[{\"created\":\"0001-01-01T00:00:00Z\"}],\"os\":\"\",\"rootfs\":{\"type\":\"layers\",\"diff_ids\":[\"sha256:19e75beae854ceb749acf78607bea3b2c0b04b74bbcc28f15e846683cf8e0b9d\"]},\"config\":{}}"}
{"method": "PUT", "target": "/v2/alice/python3-app/blobs/uploads/1eu6yk4t4l8lmbsedmyw9l?digest=sha256%3Af4c0b6312e4a446199d113e4858a0fd16aa27ea9edd150ba18dd9617cc44442d", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Content-Length: 0", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Content-Type: application/octet-stream", "Accept-Encoding: gzip"], "body": ""}
{"method": "PUT", "target": "/v2/alice/python3-app/manifests/1.0", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Content-Length: 423", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Content-Type: application/vnd.docker.distribution.manifest.v2+json", "Accept-Encoding: gzip"], "body": "{\"schemaVersion\":2,\"mediaType\":\"application/vnd.docker.distribution.manifest.v2+json\",\"config\":{\"mediaType\":\"application/vnd.docker.container.image.v1+json\",\"size\":233,\"digest\":\"sha256:f4c0b6312e4a446199d113e4858a0fd16aa27ea9edd150ba18dd9617cc44442d\"},\"layers\":[{\"mediaType\":\"application/vnd.docker.image.rootfs.diff.tar.gzip\",\"size\":144,\"digest\":\"sha256:1854e725e9328a03d70efaa86ee7b8b824558607f656efcfc81519a9c4621068\"}]}"}
{"method": "GET", "target": "/v2/token?scope=repository%3Aalice%2Fpython3-app%3Apull&service=container_registry", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Basic YWxpY2U6NTE4ZWZkZTI0NGQ1Nzg2NGVmMTI0ODdjZGVjNzNhYTgyOGU1ZGVhZg==", "Accept-Encoding: gzip"], "body": ""}
{"method": "GET", "target": "/v2/alice/python3-app/manifests/1.0", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Accept: application/vnd.docker.distribution.manifest.v1+json,application/vnd.docker.distribution.manifest.v1+prettyjws,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.index.v1+json", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Accept-Encoding: gzip"], "body": ""}
{"method": "GET", "target": "/v2/token?scope=repository%3Aalice%2Fdocker-build%3Apush%2Cpull&service=container_registry", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Basic YWxpY2U6NTE4ZWZkZTI0NGQ1Nzg2NGVmMTI0ODdjZGVjNzNhYTgyOGU1ZGVhZg==", "Accept-Encoding: gzip"], "body": ""}
{"method": "HEAD", "target": "/v2/alice/docker-build/manifests/latest", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Accept: application/vnd.docker.distribution.manifest.v1+json,application/vnd.docker.distribution.manifest.v1+prettyjws,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.index.v1+json", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0"], "body": ""}
{"method": "HEAD", "target": "/v2/alice/docker-build/blobs/sha256:f4c0b6312e4a446199d113e4858a0fd16aa27ea9edd150ba18dd9617cc44442d", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0"], "body": ""}
{"method": "HEAD", "target": "/v2/alice/docker-build/blobs/sha256:1854e725e9328a03d70efaa86ee7b8b824558607f656efcfc81519a9c4621068", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0"], "body": ""}
{"method": "GET", "target": "/v2/token?scope=repository%3Aalice%2Fdocker-build%3Apush%2Cpull&scope=repository%3Aalice%2Fpython3-app%3Apull&service=container_registry", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Basic YWxpY2U6NTE4ZWZkZTI0NGQ1Nzg2NGVmMTI0ODdjZGVjNzNhYTgyOGU1ZGVhZg==", "Accept-Encoding: gzip"], "body": ""}
{"method": "POST", "target": "/v2/alice/docker-build/blobs/uploads/?from=alice%2Fpython3-app&mount=sha256%3Af4c0b6312e4a446199d113e4858a0fd16aa27ea9edd150ba18dd9617cc44442d&origin=example.com", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Content-Length: 0", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Content-Type: application/json", "Accept-Encoding: gzip"], "body": ""}
{"method": "POST", "target": "/v2/alice/docker-build/blobs/uploads/?from=alice%2Fpython3-app&mount=sha256%3A1854e725e9328a03d70efaa86ee7b8b824558607f656efcfc81519a9c4621068&origin=example.com", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Content-Length: 0", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Content-Type: application/json", "Accept-Encoding: gzip"], "body": ""}
{"method": "PUT", "target": "/v2/alice/docker-build/manifests/latest", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Content-Length: 423", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Content-Type: application/vnd.docker.distribution.manifest.v2+json", "Accept-Encoding: gzip"], "body": "{\"schemaVersion\":2,\"mediaType\":\"application/vnd.docker.distribution.manifest.v2+json\",\"config\":{\"mediaType\":\"application/vnd.docker.container.image.v1+json\",\"size\":233,\"digest\":\"sha256:f4c0b6312e4a446199d113e4858a0fd16aa27ea9edd150ba18dd9617cc44442d\"},\"layers\":[{\"mediaType\":\"application/vnd.docker.image.rootfs.diff.tar.gzip\",\"size\":144,\"digest\":\"sha256:1854e725e9328a03d70efaa86ee7b8b824558607f656efcfc81519a9c4621068\"}]}"}
{"method": "GET", "target": "/v2/alice/python3-app/tags/list?n=1000", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Accept-Encoding: gzip"], "body": ""}
{"method": "GET", "target": "/v2/alice/python3-app/tags/list?last=1.0&n=1000", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Accept-Encoding: gzip"], "body": ""}
{"method": "GET", "target": "/v2/token?scope=repository%3Aalice%2Fdocker-build%3Apull&service=container_registry", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Basic YWxpY2U6NTE4ZWZkZTI0NGQ1Nzg2NGVmMTI0ODdjZGVjNzNhYTgyOGU1ZGVhZg==", "Accept-Encoding: gzip"], "body": ""}
{"method": "GET", "target": "/v2/alice/docker-build/manifests/latest", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Accept: application/vnd.docker.distribution.manifest.v1+json,application/vnd.docker.distribution.manifest.v1+prettyjws,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.index.v1+json", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Accept-Encoding: gzip"], "body": ""}
{"method": "GET", "target": "/v2/alice/docker-build/blobs/sha256:f4c0b6312e4a446199d113e4858a0fd16aa27ea9edd150ba18dd9617cc44442d", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Accept-Encoding: gzip"], "body": ""}
{"method": "GET", "target": "/v2/alice/docker-build/blobs/sha256:1854e725e9328a03d70efaa86ee7b8b824558607f656efcfc81519a9c4621068", "headers": ["Host: example.com", "User-Agent: crane/8a72a424fdecb4caa14f2d525e5d2503331442b5 go-containerregistry/v0.0.0-20260904000721-8a72a424fdec+dirty", "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTQ0Njg1MzYsIm5iZiI6MTc5NDM4MjEzNiwiVXNlcklEIjoxLCJTY29wZSI6ImFsbCJ9.6n67_HBXd57plKX_u-2G-eC5ojNw-dBgYZy51W7LtD0", "Accept-Encoding: gzip"], "body": ""}
{"method": "POST", "target": "/alice/tools.git/git-upload-pack", "headers": ["Host: example.com", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-upload-pack-request", "Accept: application/x-git-upload-pack-result", "Git-Protocol: version=2", "Content-Length: 473"], "body": "0011command=fetch001aagent=git/2.49.1-Linux0016object-format=sha10001000dthin-pack000fno-progress000dofs-delta0032want 0992a6293c77c3ef1f9a8d37b774deddcbef2e23\n0032want 4b5ec1aab65272b63b5b1f39bfc2c14684c063a0\n0032want 0992a6293c77c3ef1f9a8d37b774deddcbef2e23\n0032want f7ffa60396d99f61315a5a9a225fc32eb6228fdb\n0032want bbe5223a9a4f84e7c3d7206f4bdd062239e8309b\n0032want 15e1fb0f45ffdb9d4d07ccf23b8971fca09ab2ca\n0032want 15e1fb0f45ffdb9d4d07ccf23b8971fca09ab2ca\n0009done\n0000"}
{"method": "POST", "target": "/alice/tools.git/git-receive-pack", "headers": ["Host: example.com", "Authorization: Basic YWxpY2U6NTE4ZWZkZTI0NGQ1Nzg2NGVmMTI0ODdjZGVjNzNhYTgyOGU1ZGVhZg==", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-receive-pack-request", "Accept: application/x-git-receive-pack-result", "Content-Length: 519"], "body": "MDBiZDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAgY2M0ZDVlMDVlMWIzMTg0ZjQ3MTZlZDE1OGQyNWI4Y2ZhZTM4NzU0MyByZWZzL2hlYWRzL2RvY2tlci1idWlsZAAgcmVwb3J0LXN0YXR1cy12MiBzaWRlLWJhbmQtNjRrIHF1aWV0IG9iamVjdC1mb3JtYXQ9c2hhMSBhZ2VudD1naXQvMi40OS4xLUxpbnV4MDAwMFBBQ0sAAAACAAAABJENeJyVi10KwjAQBt9zirwLkp+2mwURffcSm80XLLS2tBE8vvUIzsvAwLQNsIQsA+D7mAjJkfOSMVTppEKZNUCVAwezyoZXs445yBA4KpFGVF9ZUomUibqCUjSjBoRo5N2ey2bv06iwF/npho/M64SzLvPVeuIupuD73p7cgTnqPLaGvybzGPdm6zhhN18fLT8K8AKeTXt+RMTzqiOmBNVYae5fWGAYnHice8b0jGmDCaOIb2jBcr50ZZ79x/sfSmXMOBZVkFs42YNRDgDQmAyf9gKBbcdPQPqMH4Wr8ShABeE9G0Q2EXic62RcwzjBRCYns7hErziDQav39///EVurZi0SCOP5u3X/31Yu/om6MQA3nhDoN3icyylW0M1J5AIACRECBP3ERXDJ4M4Guqo/ktYk+wdQ6W4g"}
{"method": "POST", "target": "/alice/tools.git/git-receive-pack", "headers": ["Host: example.com", "Authorization: Basic YWxpY2U6NTE4ZWZkZTI0NGQ1Nzg2NGVmMTI0ODdjZGVjNzNhYTgyOGU1ZGVhZg==", "User-Agent: git/2.49.1", "Accept-Encoding: deflate, gzip, br, zstd", "Content-Type: application/x-git-receive-pack-request", "Accept: application/x-git-receive-pack-result", "Content-Length: 193"], "body": "00bdcc4d5e05e1b3184f4716ed158d25b8cfae387543 0000000000000000000000000000000000000000 refs/heads/docker-build\u0000 report-status-v2 side-band-64k quiet object-format=sha1 agent=git/2.49.1-Linux0000"}
{"method": "GET", "target": "/alice/powershell-scripts.git/info/refs?service=git-upload-pack", "headers": ["Host: example.com", "User-Agent: git/2.49.1", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "Pragma: no-cache", "Git-Protocol: version=2"], "body": ""}
{"method": "GET", "target": "/repo-avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/explore/repos", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/explore/users?q=alice", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba?size=128", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/explore/users?q=alice", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/alice/tools/raw/branch/main/docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/alice/tools/issues", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/alice/tools/releases/tag/v1.1.0", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/alice/tools/wiki", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/alice/-/packages/generic/tools/1.2.0", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/alice/-/packages/container/python3-app/1.0", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/user/login?redirect_to=%2Falice%2Ftools%2F_edit%2Fmain%2Fdocker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/alice/tools/releases/download/v1.1.0/tools-linux-amd64.tar.gz", "headers": ["user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "accept: */*", "accept-encoding: gzip,deflate,br", "cookie: redirect_to=%2Falice%2Ftools%2F_edit%2Fmain%2Fdocker-compose.yml", "Host: example.com", "Connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/alice/tools/releases/download/v1.2.0/install-script.sh", "headers": ["user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "accept: */*", "accept-encoding: gzip,deflate,br", "cookie: redirect_to=%2Falice%2Ftools%2F_edit%2Fmain%2Fdocker-compose.yml", "Host: example.com", "Connection: keep-alive"], "body": ""}
{"method": "POST", "target": "/user/login", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 43", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/login?redirect_to=%2Falice%2Ftools%2F_edit%2Fmain%2Fdocker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: redirect_to=%2Falice%2Ftools%2F_edit%2Fmain%2Fdocker-compose.yml"], "body": "user_name=alice&password=Hv4-mQz8-Lr2x-Tp9w"}
{"method": "GET", "target": "/alice/tools/_edit/main/docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/login?redirect_to=%2Falice%2Ftools%2F_edit%2Fmain%2Fdocker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=371401f8fa63ecd5; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/eventsource.sharedworker.js?v=17.0.1~gitea-1.22.0", "headers": ["Host: example.com", "Connection: keep-alive", "Accept: */*", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept-Encoding: gzip, deflate", "Cookie: session=371401f8fa63ecd5; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/-/packages/generic/install-script/1.0.0", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=371401f8fa63ecd5; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/-/packages?q=python3&type=container", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=371401f8fa63ecd5; lang=en-US"], "body": ""}
{"method": "GET", "target": "/avatars/53bce4f1dfa0fe8e7ca126f91b35d3a6?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/-/packages?q=python3&type=container", "Accept-Encoding: gzip, deflate", "Cookie: session=371401f8fa63ecd5; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/-/packages/container/python3-app/1.0/settings", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=371401f8fa63ecd5; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/_delete/main/.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=23e319aa978e5a4c; lang=en-US"], "body": ""}
{"method": "GET", "target": "/repo-avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/_delete/main/.htaccess", "Accept-Encoding: gzip, deflate", "Cookie: session=23e319aa978e5a4c; lang=en-US"], "body": ""}
{"method": "GET", "target": "/avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba?size=128", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/_delete/main/.htaccess", "Accept-Encoding: gzip, deflate", "Cookie: session=23e319aa978e5a4c; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/context-popup.21e2b8da.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/branches", "Accept-Encoding: gzip, deflate", "Cookie: session=23e319aa978e5a4c; lang=en-US"], "body": ""}
{"method": "POST", "target": "/alice/tools/_delete/main/.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 168", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_delete/main/.htaccess", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US"], "body": "last_commit=0992a6293c77c3ef1f9a8d37b774deddcbef2e23&commit_summary=Remove+.htaccess&commit_message=&commit_choice=direct&new_branch_name=alice-patch-1&commit_mail_id=1"}
{"method": "GET", "target": "/alice/tools/src/branch/main/", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_delete/main/.htaccess", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US; flash=success%3DFile%2B%2522.htaccess%2522%2Bhas%2Bbeen%2Bdeleted."], "body": ""}
{"method": "GET", "target": "/assets/css/repo-branch-tag-selector.d9ca1da2.css", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/alice/tools/src/branch/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/repo-branch-tag-selector.f396185d.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/src/branch/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/_delete/main/scripts/install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US"], "body": ""}
{"method": "POST", "target": "/alice/tools/_delete/main/scripts/install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 186", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_delete/main/scripts/install-script.sh", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US"], "body": "last_commit=cf1f6e0dcb6e2685ce4ffcc1225d31c65cd64d26&commit_summary=Remove+scripts%2Finstall-script.sh&commit_message=&commit_choice=direct&new_branch_name=alice-patch-1&commit_mail_id=1"}
{"method": "GET", "target": "/alice/tools/src/branch/main/scripts", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_delete/main/scripts/install-script.sh", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US; flash=success%3DFile%2B%2522scripts%252Finstall-script.sh%2522%2Bhas%2Bbeen%2Bdeleted."], "body": ""}
{"method": "GET", "target": "/alice/tools/src/branch/main", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US"], "body": ""}
{"method": "POST", "target": "/alice/tools/branches/_new/branch/main", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 28", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/src/branch/main", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US"], "body": "new_branch_name=docker-build"}
{"method": "GET", "target": "/alice/tools/src/branch/docker-build/", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/src/branch/main", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US; flash=success%3DBranch%2B%2522docker-build%2522%2Bhas%2Bbeen%2Bcreated."], "body": ""}
{"method": "POST", "target": "/alice/tools/branches/delete?name=docker-build&page=1", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 44", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundarylgvDcSqmeTmS8iNm", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/branches", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US"], "body": "------WebKitFormBoundarylgvDcSqmeTmS8iNm--\r\n"}
{"method": "GET", "target": "/alice/tools/branches?page=1", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/branches", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US; flash=success%3DBranch%2B%2522docker-build%2522%2Bhas%2Bbeen%2Bdeleted."], "body": ""}
{"method": "POST", "target": "/alice/tools/settings/rename_branch", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 52", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/branches", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US"], "body": "from=feat%2Fdocker-support&to=feat%2Fpython3-support"}
{"method": "GET", "target": "/alice/tools/branches", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/branches", "Accept-Encoding: gzip, deflate", "Cookie: session=8ddd7a03bf2aa4b5; lang=en-US; flash=success%3DBranch%2Bfeat%252Fdocker-support%2Bwas%2Bsuccessfully%2Brenamed%2Bto%2Bfeat%252Fpython3-support."], "body": ""}
{"method": "GET", "target": "/repo-avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/wiki/?action=_new", "Accept-Encoding: gzip, deflate", "Cookie: session=80d11f0a28139f08; lang=en-US"], "body": ""}
{"method": "POST", "target": "/alice/tools/wiki/?action=_new", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 180", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/wiki/?action=_new", "Accept-Encoding: gzip, deflate", "Cookie: session=80d11f0a28139f08; lang=en-US"], "body": "title=Python3+setup&content=Install+python3%2C+or+powershell+on+Windows.+Run+ls+-la+to+check+that+.htaccess+and+docker-compose.yml+are+there%2C+then+run+install-script.sh.&message="}
{"method": "GET", "target": "/alice/tools/wiki/Python3-setup", "headers": ["Host: example.com", "Connection: keep-alive", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/wiki/?action=_new", "Accept-Encoding: gzip, deflate", "Cookie: session=80d11f0a28139f08; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/wiki/Python3-setup.md", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/wiki/search?q=python3", "Accept-Encoding: gzip, deflate", "Cookie: session=80d11f0a28139f08; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/wiki/Python3-setup.md", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/wiki/search?q=powershell", "Accept-Encoding: gzip, deflate", "Cookie: session=80d11f0a28139f08; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/wiki/Python3-setup.md", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/wiki/search?q=ls%20-la", "Accept-Encoding: gzip, deflate", "Cookie: session=80d11f0a28139f08; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/wiki/Python3-setup.md", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/wiki/search?q=.htaccess", "Accept-Encoding: gzip, deflate", "Cookie: session=80d11f0a28139f08; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/wiki/Python3-setup.md", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/wiki/search?q=docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=80d11f0a28139f08; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/wiki/Python3-setup.md", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/wiki/search?q=install-script.sh", "Accept-Encoding: gzip, deflate", "Cookie: session=80d11f0a28139f08; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/powershell-scripts", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/explore/repos?q=powershell", "Accept-Encoding: gzip, deflate", "Cookie: session=f3aadd45d83b7135; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/css/repo-branch-tag-selector.d9ca1da2.css", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/alice/powershell-scripts", "Accept-Encoding: gzip, deflate", "Cookie: session=f3aadd45d83b7135; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/repo-branch-tag-selector.f396185d.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/powershell-scripts", "Accept-Encoding: gzip, deflate", "Cookie: session=f3aadd45d83b7135; lang=en-US"], "body": ""}
{"method": "GET", "target": "/repo-avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice/tools/branches?q=python3", "Accept-Encoding: gzip, deflate", "Cookie: session=f3aadd45d83b7135; lang=en-US"], "body": ""}
{"method": "GET", "target": "/assets/js/context-popup.21e2b8da.js", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://example.com/alice/tools/branches?q=python3", "Accept-Encoding: gzip, deflate", "Cookie: session=f3aadd45d83b7135; lang=en-US"], "body": ""}
{"method": "GET", "target": "/alice/tools/src/branch/python3-migration", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/branches?q=python3", "Accept-Encoding: gzip, deflate", "Cookie: session=f3aadd45d83b7135; lang=en-US"], "body": ""}
{"method": "GET", "target": "/repo-avatars/f9e66d43eb4888aac5d9d93745661631a09b4c73969226a3e89b2911ece215ba", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice", "Accept-Encoding: gzip, deflate", "Cookie: session=00a162ad65e95a87; lang=en-US"], "body": ""}
{"method": "GET", "target": "/avatars/53bce4f1dfa0fe8e7ca126f91b35d3a6?size=64", "headers": ["Host: example.com", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/alice", "Accept-Encoding: gzip, deflate", "Cookie: session=00a162ad65e95a87; lang=en-US"], "body": ""}
{"method": "GET", "target": "/acme?q=python3&sort=recentupdate", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/acme", "Accept-Encoding: gzip, deflate", "Cookie: session=00a162ad65e95a87; lang=en-US"], "body": ""}
+1 -1
View File
@@ -5,7 +5,7 @@
# before the rules it switches off, so this file is loaded before the Core Rule
# Set's rules. Each comment names a request a rule refused. An exclusion on a
# field of a body applies only when the query has no parameter of that name, so
# it matters only while SWW_WAF_BODY_LIMIT is set.
# it matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
#
# The apps encrypt the names, notes, addresses and file names of vault items,
# folders, collections and sends before they send them. These values pass
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"testing"
)
// vaultwardenThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
// vaultwardenThreshold is SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD's default.
const vaultwardenThreshold = 5
// Paths of Vaultwarden's API and admin page.
+1 -1
View File
@@ -3,7 +3,7 @@
# the rules it switches off, so this file is loaded before the Core Rule Set's
# rules. Each comment names a request the rule refused. An exclusion on a field
# of a body applies only when the query has no parameter of that name, so it
# matters only while SWW_WAF_BODY_LIMIT is set.
# matters only while SWW_WAF_CORE_RULE_SET_BODY_LIMIT is set.
# WordPress takes a call to its REST API at any of four addresses:
# /wp-json/wp/v2/posts/1
+3 -3
View File
@@ -24,7 +24,7 @@ const quietTime = 2 * time.Second
// FileParams are what OpenFile needs.
type FileParams struct {
// Path is the lookup database, the IPinfo Lite file in its .mmdb form
// (SWW_LOOKUP_DB_PATH).
// (SWW_WAF_LOOKUP_DB_PATH).
Path string
// Now tells the time, normally time.Now in UTC.
Now func() time.Time
@@ -231,12 +231,12 @@ func (f *File) use(reader *maxminddb.Reader) {
func read(path string) (*maxminddb.Reader, error) {
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
if err != nil {
return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH cannot be read: %w", err)
return nil, fmt.Errorf("SWW_WAF_LOOKUP_DB_PATH cannot be read: %w", err)
}
reader, err := maxminddb.OpenBytes(data)
if err != nil {
return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
return nil, fmt.Errorf("SWW_WAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
}
return reader, nil
+3 -3
View File
@@ -99,9 +99,9 @@ func TestFileThatCannotBeReadIsAnError(t *testing.T) {
writeFile(t, notDatabase, "not a lookup database\n")
for path, want := range map[string]string{
missing: "SWW_LOOKUP_DB_PATH cannot be read: open " + missing +
missing: "SWW_WAF_LOOKUP_DB_PATH cannot be read: open " + missing +
": no such file or directory",
notDatabase: "SWW_LOOKUP_DB_PATH " + notDatabase +
notDatabase: "SWW_WAF_LOOKUP_DB_PATH " + notDatabase +
" is not a .mmdb file: error opening database: invalid MaxMind DB file",
} {
_, err := OpenFile(FileParams{
@@ -216,7 +216,7 @@ func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(t *testing.T)
Reason: "the lookup database cannot be read, and the one read before stays in use",
Detail: map[string]any{
"file": path,
"error": "SWW_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
"error": "SWW_WAF_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
"error opening database: invalid MaxMind DB file",
},
})
+2 -2
View File
@@ -1,6 +1,6 @@
// Package lookup looks up each client's AS number and country, through
// the GeoJS web service or in the lookup database, the IPinfo Lite file
// SWW_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
// SWW_WAF_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
// most 100,000 clients and for 7 days each, and are written to
// lookups.json and read from it by the state package.
package lookup
@@ -66,7 +66,7 @@ type Params struct {
URL string
// Timeout is how long a request waits for its client's first answer,
// and how long a request to GeoJS may take before it is abandoned
// (SWW_LOOKUP_TIMEOUT).
// (SWW_WAF_LOOKUP_TIMEOUT).
Timeout time.Duration
// Wait is true when a setting needs each request's answer before the
// request goes on. Otherwise no request waits for one.
+1 -1
View File
@@ -42,7 +42,7 @@ type busiest struct {
func newCountries(topN int) *busiest {
countries := newBusiest(topN, "country", "the client's country")
countries.refused = counterVec("sww_country_list_refusals_total",
"Requests SWW_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
"Requests SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
[]string{"country"})
return countries
+22 -20
View File
@@ -62,8 +62,8 @@ type Metrics struct {
// New returns the metrics, with the Go runtime's and the process's own.
// topN is how many countries and how many AS numbers get series of their
// own (SWW_METRICS_TOP_N). Every metric carries instanceName
// (SWW_INSTANCE_NAME) as its label instance.
// own (SWW_WAF_METRICS_TOP_N). Every metric carries instanceName
// (SWW_WAF_INSTANCE_NAME) as its label instance.
//
//nolint:funlen // a few lines for each metric, a list that grows with them
func New(topN int, instanceName string) *Metrics {
@@ -102,7 +102,7 @@ func New(topN int, instanceName string) *Metrics {
offences: counterVec("sww_offences_total",
"Offences, by kind.", []string{"kind"}),
coreRuleSetMatches: counterVec("sww_core_rule_set_matches_total",
"Requests that matched a rule of the Core Rule Set, by SWW_WAF_MODE "+
"Requests that matched a rule of the Core Rule Set, by SWW_WAF_CORE_RULE_SET_MODE "+
"and the rule's id.", []string{"mode", "rule_id"}),
countries: newCountries(topN),
asns: newASNs(topN),
@@ -120,17 +120,19 @@ func New(topN int, instanceName string) *Metrics {
"went on without it, because GeoJS had not given it in time.",
}),
stateFileWrites: counterVec("sww_state_file_writes_total",
"Writes of each state file.", byFile),
"Writes of each state or cache file.", byFile),
stateFileWriteFailures: counterVec("sww_state_file_write_failures_total",
"Writes of each state file that failed.", byFile),
"Writes of each state or cache file that failed.", byFile),
stateFileLastWrite: gaugeVec("sww_state_file_last_write_timestamp_seconds",
"When each state file was last written, in seconds since 1970.", byFile),
"When each state or cache file was last written, in seconds since 1970.",
byFile),
stateFileSize: gaugeVec("sww_state_file_size_bytes",
"The size of each state file, as it was last written.", byFile),
"The size of each state or cache file, as it was last written.", byFile),
stateFileEditsTakenIn: counterVec("sww_state_file_edits_taken_in_total",
"Edits of each state file taken in while running.", byFile),
"Edits of each state or cache file taken in while running.", byFile),
stateFileEditsSetAside: counterVec("sww_state_file_edits_set_aside_total",
"Edits of each state file renamed to <name>.bad because they did not parse.",
"Edits of each state or cache file renamed to <name>.bad because they "+
"did not parse.",
byFile),
}
@@ -214,13 +216,13 @@ func (m *Metrics) AddRules(ruleFiles *rules.Files) {
}
// AddRemoteLog adds the metrics of sending the log lines to
// SWW_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
// SWW_WAF_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
// lines sent, those dropped, and those waiting in the buffer.
func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
m.registry.MustRegister(
prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "sww_remote_log_lines_sent_total",
Help: "Log lines sent to SWW_LOG_REMOTE_URL.",
Help: "Log lines sent to SWW_WAF_LOG_REMOTE_URL.",
}, func() float64 {
return float64(remote.Sent())
}),
@@ -313,7 +315,7 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL
// AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb,
// read from abuseIPDB as the metrics are asked for: the checks made, those
// that failed, and how many checks the day's budget has left. It is
// called once, after AddReputation, while SWW_ABUSEIPDB_KEY is set.
// called once, after AddReputation, while SWW_WAF_ABUSEIPDB_KEY is set.
func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource}
@@ -322,7 +324,7 @@ func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
m.registry.MustRegister(
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "sww_reputation_daily_budget_remaining",
Help: "Checks of the day's SWW_ABUSEIPDB_DAILY_BUDGET not yet spent.",
Help: "Checks of the day's SWW_WAF_ABUSEIPDB_DAILY_BUDGET not yet spent.",
ConstLabels: source,
}, func() float64 {
return float64(abuseIPDB.BudgetLeft())
@@ -363,8 +365,8 @@ func (m *Metrics) AddAlerts(queue *alerts.Queue) {
}),
prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "sww_alerts_suppressed_total",
Help: "Alerts held back: repeats within SWW_ALERT_COOLDOWN, and " +
"alerts past SWW_ALERT_MAX_PER_HOUR, for the hour's summary.",
Help: "Alerts held back: repeats within SWW_WAF_ALERT_COOLDOWN, and " +
"alerts past SWW_WAF_ALERT_MAX_PER_HOUR, for the hour's summary.",
ConstLabels: destination,
}, func() float64 {
return float64(queue.Suppressed())
@@ -442,13 +444,13 @@ func (m *Metrics) RuleMatched(id, action string) {
}
// CoreRuleSetMatched counts a request that matched the Core Rule Set's rule id,
// with SWW_WAF_MODE at mode.
// with SWW_WAF_CORE_RULE_SET_MODE at mode.
func (m *Metrics) CoreRuleSetMatched(mode string, id int) {
m.coreRuleSetMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
}
// StateFileWritten counts a write of the state file name, of size bytes,
// that ended with err.
// StateFileWritten counts a write of the state or cache file name, of size
// bytes, that ended with err.
func (m *Metrics) StateFileWritten(name string, size int, err error) {
m.stateFileWrites.WithLabelValues(name).Inc()
@@ -466,13 +468,13 @@ func (m *Metrics) StateFileWritten(name string, size int, err error) {
m.stateFileSize.WithLabelValues(name).Set(float64(size))
}
// StateFileEditTakenIn counts an admin's edit of the state file name
// StateFileEditTakenIn counts an admin's edit of the state or cache file name
// taken in while sww runs.
func (m *Metrics) StateFileEditTakenIn(name string) {
m.stateFileEditsTakenIn.WithLabelValues(name).Inc()
}
// StateFileEditSetAside counts an admin's edit of the state file name
// StateFileEditSetAside counts an admin's edit of the state or cache file name
// renamed to name.bad because it did not parse.
func (m *Metrics) StateFileEditSetAside(name string) {
m.stateFileEditsSetAside.WithLabelValues(name).Inc()
+2 -2
View File
@@ -2,7 +2,7 @@
// and bytes counted over a minute, an hour and a day, as the "Counting
// method" section of SPEC.md describes, which tell when a request takes
// the client over a rate limit or a byte limit, and each client's history
// since it was first seen. At most SWW_MAX_TRACKED_CLIENTS clients are
// since it was first seen. At most SWW_WAF_MAX_TRACKED_CLIENTS clients are
// kept, in memory, and written to clients.json and read from it by the
// state package.
package ratelimit
@@ -157,7 +157,7 @@ type Request struct {
}
// New returns a Limiter for limits, with no client counted yet, whose
// table holds at most maxClients clients (SWW_MAX_TRACKED_CLIENTS). Past
// table holds at most maxClients clients (SWW_WAF_MAX_TRACKED_CLIENTS). Past
// it, the least recently seen client is dropped, with its history, and
// starts afresh if it comes back.
func New(limits Limits, maxClients int) *Limiter {
+1 -1
View File
@@ -13,7 +13,7 @@ import (
const limit = 3
// tableSize is the most clients the tests' tables hold, the default of
// SWW_MAX_TRACKED_CLIENTS.
// SWW_WAF_MAX_TRACKED_CLIENTS.
const tableSize = 20000
// whole is the percentage of each limit a client gets when nothing lowers
+8 -8
View File
@@ -1,5 +1,5 @@
// Package remotelog sends the lines sww writes on stdout to the
// remote log endpoint, SWW_LOG_REMOTE_URL, as the "Request log" section
// remote log endpoint, SWW_WAF_LOG_REMOTE_URL, as the "Request log" section
// of SPEC.md describes: each line as the message of an RFC 5424 syslog
// record, over UDP, TCP or TLS. Lines wait in a bounded buffer, so a slow
// or unreachable endpoint never holds up a request or stdout.
@@ -24,7 +24,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// The forms of SWW_LOG_REMOTE_URL, by its scheme.
// The forms of SWW_WAF_LOG_REMOTE_URL, by its scheme.
const (
SchemeUDP = "syslog+udp"
SchemeTCP = "syslog+tcp"
@@ -56,18 +56,18 @@ const (
// Params are what New needs.
type Params struct {
// URL is the endpoint (SWW_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or
// URL is the endpoint (SWW_WAF_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or
// SchemeTLS, a host and a port.
URL *url.URL
// RootCAs are the certificates a SchemeTLS endpoint's certificate
// must chain to (SWW_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
// must chain to (SWW_WAF_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
RootCAs *x509.CertPool
// Buffer is the most lines held while they wait to be sent
// (SWW_LOG_REMOTE_BUFFER).
// (SWW_WAF_LOG_REMOTE_BUFFER).
Buffer int
// Facility is the number of the records' syslog facility
// (SWW_LOG_REMOTE_FACILITY), and AppName their APP-NAME
// (SWW_LOG_REMOTE_APP_NAME).
// (SWW_WAF_LOG_REMOTE_FACILITY), and AppName their APP-NAME
// (SWW_WAF_LOG_REMOTE_APP_NAME).
Facility int
AppName string
}
@@ -243,7 +243,7 @@ func (s *Sender) send(ctx context.Context, processLog *slog.Logger) net.Conn {
}
}
processLog.Warn("sending to SWW_LOG_REMOTE_URL failed",
processLog.Warn("sending to SWW_WAF_LOG_REMOTE_URL failed",
"error", err.Error(), "connecting_again_in", delay.String())
select {
+1 -1
View File
@@ -586,7 +586,7 @@ func wantRetries(t *testing.T, logged *output, delays ...string) {
var fields map[string]any
err := json.Unmarshal([]byte(line), &fields)
if err != nil || fields["msg"] != "sending to SWW_LOG_REMOTE_URL failed" {
if err != nil || fields["msg"] != "sending to SWW_WAF_LOG_REMOTE_URL failed" {
t.Fatalf("logged %q", line)
}
+45 -29
View File
@@ -36,38 +36,37 @@ var (
"checks spent; none is made until the day ends at 00:00 UTC")
)
// Score is what AbuseIPDB said about a client, as reputation.json holds
// it: the client, an IPv4 address or an IPv6 group, its abuse confidence
// score, from 0 to 100, and when AbuseIPDB answered.
// Score is what AbuseIPDB said about a client, as abuseipdb-scores.json
// holds it: the client, an IPv4 address or an IPv6 group, its abuse
// confidence score, from 0 to 100, and when AbuseIPDB answered.
type Score struct {
Client netip.Prefix `json:"client"`
Score int64 `json:"score"`
Fetched time.Time `json:"fetched"`
}
// Checks are what reputation.json keeps of the checks of clients with
// AbuseIPDB: the day, in UTC, of the checks Spent counts, zero before the
// first, and the scores still in use.
type Checks struct {
Day civil.Date `json:"day,omitzero"`
Spent int `json:"spent"`
Scores []Score `json:"scores"`
// ChecksSpent is what abuseipdb-checks-spent.json keeps of the checks of
// clients with AbuseIPDB: the day, in UTC, of the checks Spent counts,
// zero before the first.
type ChecksSpent struct {
Day civil.Date
Spent int
}
// AbuseIPDBParams are what NewAbuseIPDB needs.
type AbuseIPDBParams struct {
// URL is where clients are checked, normally AbuseIPDBURL, with Key,
// the account's key (SWW_ABUSEIPDB_KEY).
// the account's key (SWW_WAF_ABUSEIPDB_KEY).
URL string
Key string
// MinScore is the least score that is a hit (SWW_ABUSEIPDB_MIN_SCORE),
// MinScore is the least score that is a hit (SWW_WAF_ABUSEIPDB_MIN_SCORE),
// and DailyBudget the most checks made in a day, in UTC
// (SWW_ABUSEIPDB_DAILY_BUDGET).
// (SWW_WAF_ABUSEIPDB_DAILY_BUDGET).
MinScore int64
DailyBudget int
// CacheTTL is how long a score is used after it was fetched
// (SWW_REPUTATION_CACHE_TTL), and Timeout how long a check may take
// (SWW_REPUTATION_TIMEOUT).
// (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a check may take
// (SWW_WAF_REPUTATION_TIMEOUT).
CacheTTL time.Duration
Timeout time.Duration
// Now tells the time, normally time.Now in UTC.
@@ -196,35 +195,35 @@ func (a *AbuseIPDB) BudgetLeft() int {
return max(a.params.DailyBudget-a.spent, 0)
}
// Snapshot returns the checks spent and every score still in use, sorted
// by client, as reputation.json keeps them.
func (a *AbuseIPDB) Snapshot() Checks {
// Snapshot returns every score still in use, sorted by client, as
// abuseipdb-scores.json keeps them.
func (a *AbuseIPDB) Snapshot() []Score {
a.mu.Lock()
now := a.params.Now()
checks := Checks{Day: a.day, Spent: a.spent, Scores: make([]Score, 0, a.scores.Len())}
scores := make([]Score, 0, a.scores.Len())
for _, kept := range a.scores.Values() {
if now.Sub(kept.Fetched) < a.params.CacheTTL {
checks.Scores = append(checks.Scores, kept)
scores = append(scores, kept)
}
}
a.mu.Unlock()
slices.SortFunc(checks.Scores, func(x, y Score) int {
slices.SortFunc(scores, func(x, y Score) int {
return x.Client.Compare(y.Client)
})
return checks
return scores
}
// Load keeps checks, read from reputation.json, in place of those it
// keeps, but for the scores past maxVerdicts, those fetched longest ago.
// One fetched CacheTTL ago or more is neither used nor written, as for any
// score.
func (a *AbuseIPDB) Load(checks Checks) {
scores := slices.Clone(checks.Scores)
// Load keeps scores, read from abuseipdb-scores.json, in place of those
// it keeps, but for the scores past maxVerdicts, those fetched longest
// ago. One fetched CacheTTL ago or more is neither used nor written, as
// for any score.
func (a *AbuseIPDB) Load(scores []Score) {
scores = slices.Clone(scores)
slices.SortStableFunc(scores, func(x, y Score) int {
return x.Fetched.Compare(y.Fetched)
})
@@ -232,7 +231,6 @@ func (a *AbuseIPDB) Load(checks Checks) {
a.mu.Lock()
defer a.mu.Unlock()
a.day, a.spent = checks.Day, checks.Spent
a.scores.Purge()
for _, kept := range scores {
@@ -240,6 +238,24 @@ func (a *AbuseIPDB) Load(checks Checks) {
}
}
// ChecksSpent returns the checks spent, as abuseipdb-checks-spent.json
// keeps them.
func (a *AbuseIPDB) ChecksSpent() ChecksSpent {
a.mu.Lock()
defer a.mu.Unlock()
return ChecksSpent{Day: a.day, Spent: a.spent}
}
// LoadChecksSpent keeps checks, read from abuseipdb-checks-spent.json, in
// place of the checks spent.
func (a *AbuseIPDB) LoadChecksSpent(checks ChecksSpent) {
a.mu.Lock()
defer a.mu.Unlock()
a.day, a.spent = checks.Day, checks.Spent
}
// check checks client with AbuseIPDB by addr, one of its addresses, keeps
// the score as client's, and notes the check as no longer under way. A
// check that fails gives no score: it is counted, logged and raised as a
+15 -16
View File
@@ -17,6 +17,7 @@ import (
"testing/synctest"
"time"
"cloud.google.com/go/civil"
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/metrics"
"sneak.berlin/go/smallwebwaf/internal/reputation"
@@ -179,11 +180,13 @@ func TestDailyBudgetKeptAcrossARestartAndWholeAgainAsTheDayEnds(t *testing.T) {
t.Errorf("logged\n%s\nwant the budget used up", log.String())
}
// Restarted with what reputation.json keeps, it uses the scores, and
// checks no client until the day ends.
// Restarted with what abuseipdb-scores.json and
// abuseipdb-checks-spent.json keep, it uses the scores, and checks
// no client until the day ends.
restarted := &abuseIPDBStandIn{}
again := newAbuseIPDB(restarted, p)
again.Load(checker.Snapshot())
again.LoadChecksSpent(checker.ChecksSpent())
wantScore(t, again, suspect, true, 100, true)
wantBudgetLeft(t, again, 0)
@@ -224,13 +227,9 @@ func TestChecksAreCountedInUTCDaysWhateverTheClocksZone(t *testing.T) {
time.Sleep(2 * time.Hour)
wantBudgetLeft(t, checker, 0)
encoded, err := json.Marshal(checker.Snapshot())
if err != nil {
t.Fatalf("encode: %v", err)
}
if !strings.Contains(string(encoded), `"day":"2000-01-01"`) {
t.Errorf("reputation.json would hold %s, want the day 2000-01-01", encoded)
want := civil.Date{Year: 2000, Month: 1, Day: 1}
if day := checker.ChecksSpent().Day; day != want {
t.Errorf("checks spent on %s, want on %s", day, want)
}
// At 00:00 UTC the budget is whole again.
@@ -302,7 +301,7 @@ func TestFailedCheckGivesNoScoreAndNoClientIsCheckedForAMinute(t *testing.T) {
wantChecked(t, abuseIPDB, suspect, other)
wantFailures(t, checker, 2)
if scores := checker.Snapshot().Scores; len(scores) != 0 {
if scores := checker.Snapshot(); len(scores) != 0 {
t.Errorf("scores %+v, want none", scores)
}
@@ -399,7 +398,7 @@ func TestKeyIsSentInTheKeyHeaderAndNeverShown(t *testing.T) {
for name, shown := range map[string]string{
"the log": log.String(), "the alerts": string(alerted),
"the metrics": scrapeMetrics(t, m), "reputation.json": string(kept),
"the metrics": scrapeMetrics(t, m), "abuseipdb-scores.json": string(kept),
} {
if strings.Contains(shown, key) {
t.Errorf("%s shows the key:\n%s", name, shown)
@@ -462,12 +461,12 @@ func TestScoreFetchedATTLAgoIsNeitherUsedNorKept(t *testing.T) {
Fetched: now.Add(-cacheTTL),
}
checker.Load(reputation.Checks{Scores: []reputation.Score{stale, inUse}})
checker.Load([]reputation.Score{stale, inUse})
wantScore(t, checker, suspect, false, 100, true)
wantScore(t, checker, other, false, 0, false)
got := checker.Snapshot().Scores
got := checker.Snapshot()
if !reflect.DeepEqual(got, []reputation.Score{inUse}) {
t.Errorf("scores %+v, want only %+v", got, inUse)
}
@@ -481,7 +480,7 @@ func TestAtMost100000ScoresKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.T)
p.Now = func() time.Time { return now }
checker := reputation.NewAbuseIPDB(p)
// 100,001 scores, listed by client, as reputation.json lists them, each
// 100,001 scores, listed by client, as abuseipdb-scores.json lists them, each
// fetched a millisecond before the one before it: the last is one too
// many.
const count = 100001
@@ -497,9 +496,9 @@ func TestAtMost100000ScoresKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.T)
addr = addr.Next()
}
checker.Load(reputation.Checks{Scores: scores})
checker.Load(scores)
got := checker.Snapshot().Scores
got := checker.Snapshot()
if len(got) != count-1 || !slices.Contains(got, scores[0]) ||
slices.Contains(got, scores[count-1]) {
t.Errorf("%d scores kept, want all but the one fetched longest ago", len(got))
+2 -2
View File
@@ -291,7 +291,7 @@ func crowdSecFailures() []crowdSecFailure {
// other than its decision list, such as the one a redirect names, and that
// the key is in none of what the fetches leave behind: log, the process
// log, the alerts waiting in queue, and the copies of lists, which
// reputation.json keeps.
// lists.json keeps.
func wantKeyNotShown(
t *testing.T, e *engine, log string, lists *reputation.Lists, queue *alerts.Queue,
) {
@@ -326,7 +326,7 @@ func TestCrowdSecDecisionListKeptAcrossARestartEndsWhenItsDecisionsDo(t *testing
lists := start(t, e, crowdSecParams())
kept := lists.Snapshot()
// Restarted half an hour later with what reputation.json keeps, and
// Restarted half an hour later with what lists.json keeps, and
// the engine down, the decision still bans, until the end it had at
// the fetch, half an hour on.
time.Sleep(30 * time.Minute)
+10 -10
View File
@@ -40,7 +40,7 @@ var (
errNotListing = errors.New("the answer is outside 127.0.0.0/8")
)
// Verdict is what a zone said about a client, as reputation.json holds
// Verdict is what a zone said about a client, as dnsbl.json holds
// it: the zone, the client's address, whether the zone lists it, and when
// the zone answered.
type Verdict struct {
@@ -53,15 +53,15 @@ type Verdict struct {
// DNSBLParams are what NewDNSBL needs.
type DNSBLParams struct {
// Zones are the DNSBL zones clients are asked about in
// (SWW_DNSBL_ZONES).
// (SWW_WAF_DNSBL_ZONES).
Zones []string
// Resolver is the resolver they are asked through
// (SWW_DNSBL_RESOLVER), or, while it is the zero AddrPort, the
// (SWW_WAF_DNSBL_RESOLVER), or, while it is the zero AddrPort, the
// host's, as /etc/resolv.conf names it.
Resolver netip.AddrPort
// CacheTTL is how long a verdict is used after it was fetched
// (SWW_REPUTATION_CACHE_TTL), and Timeout how long a query may take
// (SWW_REPUTATION_TIMEOUT).
// (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a query may take
// (SWW_WAF_REPUTATION_TIMEOUT).
CacheTTL time.Duration
Timeout time.Duration
// Now tells the time, normally time.Now in UTC.
@@ -127,13 +127,13 @@ func NewDNSBL(params DNSBLParams) *DNSBL {
}
}
// Zones returns the zones, in the order SWW_DNSBL_ZONES names them.
// Zones returns the zones, in the order SWW_WAF_DNSBL_ZONES names them.
func (d *DNSBL) Zones() []string {
return slices.Clone(d.params.Zones)
}
// ListedBy returns the zones whose verdict on addr, a client's address,
// lists it, in the order SWW_DNSBL_ZONES names them, each with its key
// lists it, in the order SWW_WAF_DNSBL_ZONES names them, each with its key
// masked, as config.MaskZoneKey masks it, since they go to the request
// log, the alerts and the metrics. A verdict is used until CacheTTL has
// passed since it was fetched. Each zone without one is asked about addr
@@ -187,7 +187,7 @@ func (d *DNSBL) Failures(zone string) int {
}
// Snapshot returns every verdict still in use, sorted by client, then by
// zone, as reputation.json lists them.
// zone, as dnsbl.json lists them.
func (d *DNSBL) Snapshot() []Verdict {
d.mu.Lock()
@@ -209,8 +209,8 @@ func (d *DNSBL) Snapshot() []Verdict {
return verdicts
}
// Load keeps verdicts, read from reputation.json, in place of those it
// keeps, but for those of a zone SWW_DNSBL_ZONES does not name, and,
// Load keeps verdicts, read from dnsbl.json, in place of those it
// keeps, but for those of a zone SWW_WAF_DNSBL_ZONES does not name, and,
// past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or
// more is neither used nor written, as for any verdict.
func (d *DNSBL) Load(verdicts []Verdict) {
+5 -5
View File
@@ -40,8 +40,8 @@ const (
// zone and otherZone are the DNSBL zones the tests name.
zone = "dnsbl.example"
otherZone = "other.example"
// cacheTTL is the tests' SWW_REPUTATION_CACHE_TTL, and timeout their
// SWW_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
// cacheTTL is the tests' SWW_WAF_REPUTATION_CACHE_TTL, and timeout their
// SWW_WAF_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
// can have Go's resolver wait for one server, so that it is the
// DNSBL's own timeout that ends a query, whatever that file says.
cacheTTL = 24 * time.Hour
@@ -389,7 +389,7 @@ func TestVerdictsKeptAcrossARestart(t *testing.T) {
t.Errorf("verdicts %+v, want %+v", kept, want)
}
// Restarted an hour later with what reputation.json keeps, it uses
// Restarted an hour later with what dnsbl.json keeps, it uses
// the verdicts, and asks the zone nothing, until the TTL has passed
// since they were fetched.
time.Sleep(time.Hour)
@@ -419,7 +419,7 @@ func TestNeitherAVerdictOfAZoneNotNamedNorOnePastItsTTLIsKept(t *testing.T) {
dnsbl := reputation.NewDNSBL(p)
// The last verdict still in use, one fetched a TTL ago, and one of a
// zone SWW_DNSBL_ZONES does not name.
// zone SWW_WAF_DNSBL_ZONES does not name.
inUse := reputation.Verdict{
Zone: zone, Client: netip.MustParseAddr(listed), Listed: true,
Fetched: now.Add(-cacheTTL + time.Nanosecond),
@@ -446,7 +446,7 @@ func TestAtMost100000VerdictsKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.
p.Now = func() time.Time { return now }
dnsbl := reputation.NewDNSBL(p)
// 100,001 verdicts, listed by client, as reputation.json lists them,
// 100,001 verdicts, listed by client, as dnsbl.json lists them,
// each fetched a millisecond before the one before it: the last is one
// too many.
const count = 100001
+19 -19
View File
@@ -1,14 +1,14 @@
// Package reputation fetches the lists the settings name by URL: the
// blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines
// SWW_ASN_LIMIT_PERCENT_URL names, the address lists of SWW_TAG_LISTS, and
// the decision list of the CrowdSec engine SWW_CROWDSEC_LAPI_URL names. It
// blocklists of SWW_WAF_BLOCKLIST_URLS, the file of AS:percent lines
// SWW_WAF_ASN_LIMIT_PERCENT_URL names, the address lists of SWW_WAF_TAG_LISTS, and
// the decision list of the CrowdSec engine SWW_WAF_CROWDSEC_LAPI_URL names. It
// keeps the last good copy of
// each, whole, comment lines included, which is used while a fetch fails,
// and when each was last tried. It also asks the DNSBL zones of
// SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks
// SWW_WAF_DNSBL_ZONES about clients, and keeps their verdicts, and checks
// clients with AbuseIPDB, and keeps their scores and the checks spent
// today. The state package writes all of these to reputation.json and
// reads them from it, so that a restart keeps them too.
// today. The state package writes all of these to files and reads them
// from there, so that a restart keeps them too.
package reputation
import (
@@ -54,7 +54,7 @@ var (
"does not give an address or a netblock and a duration, such as 4h0m0s")
)
// List is a list as reputation.json holds it: the URL it is fetched from,
// List is a list as lists.json holds it: the URL it is fetched from,
// when it was last tried, the fetch failed or not, and its last good copy:
// when that was fetched, and its lines, as fetched, comment lines
// included, both left out while no fetch of it has succeeded.
@@ -67,20 +67,20 @@ type List struct {
// Params are what New needs.
type Params struct {
// BlocklistURLs are the blocklists (SWW_BLOCKLIST_URLS), and
// BlocklistURLs are the blocklists (SWW_WAF_BLOCKLIST_URLS), and
// ASNLimitPercentURL the file of AS:percent lines
// (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset.
// (SWW_WAF_ASN_LIMIT_PERCENT_URL), "" while it is unset.
BlocklistURLs []string
ASNLimitPercentURL string
// TagLists are the address lists that add a tag (SWW_TAG_LISTS).
// TagLists are the address lists that add a tag (SWW_WAF_TAG_LISTS).
TagLists []config.TagList
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
// SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
// (SWW_CROWDSEC_LAPI_KEY).
// SWW_WAF_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
// (SWW_WAF_CROWDSEC_LAPI_KEY).
CrowdSecDecisionsURL string
CrowdSecKey string
// Refresh is how long after a list was last fetched or tried it is
// fetched again (SWW_BLOCKLIST_REFRESH), but for the CrowdSec decision
// fetched again (SWW_WAF_BLOCKLIST_REFRESH), but for the CrowdSec decision
// list, which is fetched again crowdSecRefresh after.
Refresh time.Duration
// Now tells the time, normally time.Now in UTC.
@@ -106,7 +106,7 @@ type Lists struct {
lists map[string]*list
}
// list is one list: what reputation.json keeps of it, its last try, zero
// list is one list: what lists.json keeps of it, its last try, zero
// before the first, and its last good copy, what that copy says, and how
// many fetches of it failed.
type list struct {
@@ -155,7 +155,7 @@ func New(params Params) *Lists {
}
// URLs returns the URL of every list: the blocklists' in the order
// SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then the
// SWW_WAF_BLOCKLIST_URLS names them, then SWW_WAF_ASN_LIMIT_PERCENT_URL, then the
// tag lists', then the CrowdSec decision list's.
func (l *Lists) URLs() []string {
urls := slices.Clone(l.params.BlocklistURLs)
@@ -175,7 +175,7 @@ func (l *Lists) URLs() []string {
}
// ListedBy returns the URLs of the blocklists whose copy lists addr, in
// the order SWW_BLOCKLIST_URLS names them.
// the order SWW_WAF_BLOCKLIST_URLS names them.
func (l *Lists) ListedBy(addr netip.Addr) []string {
l.mu.Lock()
defer l.mu.Unlock()
@@ -272,7 +272,7 @@ func (l *Lists) Failures(listURL string) int {
// Run fetches each list once it is due, as due tells, until ctx is done. A
// list never tried is fetched at once, and so is one that is due by its
// last try or copy read from reputation.json.
// last try or copy read from lists.json.
func (l *Lists) Run(ctx context.Context) {
if len(l.lists) == 0 {
return
@@ -292,7 +292,7 @@ func (l *Lists) Run(ctx context.Context) {
}
// Snapshot returns each list that has been tried, with its copy, if it
// has one, sorted by URL, as reputation.json lists them.
// has one, sorted by URL, as lists.json lists them.
func (l *Lists) Snapshot() []List {
l.mu.Lock()
@@ -313,7 +313,7 @@ func (l *Lists) Snapshot() []List {
return tried
}
// Load puts lists, read from reputation.json, in place of the last tries
// Load puts lists, read from lists.json, in place of the last tries
// and copies held. A list Params does not name is dropped. A copy with a
// line that parse refuses is an error, and then nothing changes.
func (l *Lists) Load(lists []List) error {
+6 -6
View File
@@ -34,8 +34,8 @@ const (
dropURL = "https://lists.example/drop.txt"
torURL = "https://lists.example/tor.txt"
asnURL = "https://lists.example/asn.txt"
// refresh is the tests' SWW_BLOCKLIST_REFRESH, and cooldown their
// SWW_ALERT_COOLDOWN, longer than it.
// refresh is the tests' SWW_WAF_BLOCKLIST_REFRESH, and cooldown their
// SWW_WAF_ALERT_COOLDOWN, longer than it.
refresh = 24 * time.Hour
cooldown = 48 * time.Hour
// drop is a blocklist as the Spamhaus DROP list is written, with an
@@ -116,7 +116,7 @@ func TestClientIsListedByEachBlocklistThatListsIt(t *testing.T) {
}}
lists := start(t, servers, params(torURL, dropURL))
// In the order SWW_BLOCKLIST_URLS names them.
// In the order SWW_WAF_BLOCKLIST_URLS names them.
wantListedBy(t, lists, "203.0.113.9", torURL, dropURL)
wantListedBy(t, lists, "203.0.113.8", dropURL)
})
@@ -260,7 +260,7 @@ func TestKeptCopyIsFetchedAgainOnceRefreshHasPassedSinceItWasFetched(t *testing.
lists.SetTransport(servers)
// drop.txt was fetched an hour ago, and tor.txt a refresh ago, as
// reputation.json says at start.
// lists.json says at start.
err := lists.Load([]reputation.List{
{URL: dropURL, Fetched: time.Now().Add(-time.Hour), Lines: []string{"203.0.113.9"}},
{URL: torURL, Fetched: time.Now().Add(-refresh), Lines: []string{"198.51.100.9"}},
@@ -297,7 +297,7 @@ func TestRestartWaitsRefreshAfterTheLastTryEvenOneThatFailed(t *testing.T) {
time.Sleep(refresh)
wantFetches(t, servers, 2)
// Restarted with what reputation.json keeps, it waits a refresh
// Restarted with what lists.json keeps, it waits a refresh
// after the failed try, as it does while it runs.
restarted := &standIn{lists: map[string]string{dropURL: "198.51.100.2\n"}}
again := reputation.New(params(dropURL))
@@ -353,7 +353,7 @@ func TestFetchCutOffAsItStopsIsNoFailureButARestartWaitsForIt(t *testing.T) {
waiting(queue))
}
// Restarted an hour later with what reputation.json keeps, it fetches
// Restarted an hour later with what lists.json keeps, it fetches
// tor.txt, never tried, at once, and drop.txt a refresh after its
// cut-off try.
time.Sleep(time.Hour)
+19 -12
View File
@@ -5,6 +5,7 @@
package requestlog
import (
"bytes"
"encoding/json"
"fmt"
"io"
@@ -43,17 +44,17 @@ const (
ActionBlockedByBlockRule = "blocked_by_block_rule"
// ActionBlockedByCoreRuleSet is a request refused because the Core Rule
// Set scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
// Set scored it at or over SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
ActionBlockedByCoreRuleSet = "blocked_by_core_rule_set"
// ActionClientDenied is a request refused because its client is in
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
// SWW_WAF_DENY_NETS, in a blocklist while SWW_WAF_BLOCKLIST_ACTION is deny,
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
// SWW_REPUTATION_ACTION is deny, or that carries a tag in SWW_TAG_DENY.
// SWW_WAF_REPUTATION_ACTION is deny, or that carries a tag in SWW_WAF_TAG_DENY.
ActionClientDenied = "client_denied"
// ActionCountryDenied is a request refused because its client's country
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
// is not in SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES.
ActionCountryDenied = "country_denied"
// ActionOwnEndpoint is a request waf answered at one of its own
// endpoints, or a question about a path under /_sww/.
@@ -125,7 +126,7 @@ type Line struct {
ContentType string `json:"content_type,omitempty"`
// ContentLength is the length of its body the request announced.
ContentLength int64 `json:"content_length,omitempty"`
// RequestHeaders are the headers SWW_LOG_REQUEST_HEADERS names that
// RequestHeaders are the headers SWW_WAF_LOG_REQUEST_HEADERS names that
// the request carried, by name in lower case.
RequestHeaders map[string]string `json:"request_headers,omitempty"`
HasAuthorization bool `json:"has_authorization,omitempty"`
@@ -188,7 +189,7 @@ type Line struct {
Reputation []string `json:"reputation,omitempty"`
// Tags are the request's tags, sorted.
Tags []string `json:"tags,omitempty"`
// RouteWatchTagsMissing, while SWW_ROUTEWATCH_URL is set, says why the
// RouteWatchTagsMissing, while SWW_WAF_ROUTEWATCH_URL is set, says why the
// request got no tags from routewatch: no_answer_yet, request_failed or
// no_route.
RouteWatchTagsMissing string `json:"routewatch_tags_missing,omitempty"`
@@ -232,7 +233,7 @@ type RProxyLine struct {
DurationRequestMS float64 `json:"duration_request_ms"`
DurationResponseMS float64 `json:"duration_response_ms"`
// Disposition is the valid disposition waf answered or, while
// DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is ""
// DefaultDisposition is true, the value of SWW_RPROXY_DEFAULT_DISPOSITION. It is ""
// when neither gave one.
Disposition string `json:"disposition,omitempty"`
DefaultDisposition bool `json:"default_disposition,omitempty"`
@@ -252,14 +253,20 @@ func WriteRProxyLine(w io.Writer, line *RProxyLine) error {
return writeJSONLine(w, line)
}
// writeJSONLine writes line to w as one JSON line.
// writeJSONLine writes line to w as one JSON line. It writes "&", "<" and
// ">" as they are, so a query string reads as it was sent.
func writeJSONLine(w io.Writer, line any) error {
encoded, err := json.Marshal(line)
var encoded bytes.Buffer
encoder := json.NewEncoder(&encoded)
encoder.SetEscapeHTML(false)
err := encoder.Encode(line)
if err != nil {
return fmt.Errorf("encode the request log line: %w", err)
}
_, err = w.Write(append(encoded, '\n'))
_, err = w.Write(encoded.Bytes())
if err != nil {
return fmt.Errorf("write the request log line: %w", err)
}
@@ -284,8 +291,8 @@ func Milliseconds(d time.Duration) float64 {
// NewProcessLogger returns the logger for the process's own messages:
// JSON lines on w, marked "type":"process", with the time in the same form
// as a request line's, and instanceName, SWW_INSTANCE_NAME, as instance.
// It writes only the messages at level, SWW_LOG_LEVEL, or more severe;
// as a request line's, and instanceName, SWW_WAF_INSTANCE_NAME, as instance.
// It writes only the messages at level, SWW_WAF_LOG_LEVEL, or more severe;
// the request lines Write writes are never held back.
func NewProcessLogger(
w io.Writer,
+38
View File
@@ -67,6 +67,44 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
}
}
func TestRequestLinesHoldTheQueryAndRefererAsSent(t *testing.T) {
t.Parallel()
var out bytes.Buffer
err := requestlog.Write(&out, &requestlog.Line{
Query: "a=1&b=<2>",
Referer: "https://example.com/?x=1&y=<2>",
})
if err != nil {
t.Fatalf("write: %v", err)
}
err = requestlog.WriteRProxyLine(&out, &requestlog.RProxyLine{Query: "a=1&b=<2>"})
if err != nil {
t.Fatalf("write the rproxy line: %v", err)
}
lines := strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("wrote %q, want two lines", out.String())
}
for _, line := range lines {
if !json.Valid([]byte(line)) {
t.Errorf("%s is not valid JSON", line)
}
if !strings.Contains(line, `"query":"a=1&b=<2>"`) {
t.Errorf("%s does not hold the query as sent", line)
}
}
if !strings.Contains(lines[0], `"referer":"https://example.com/?x=1&y=<2>"`) {
t.Errorf("%s does not hold the referer as sent", lines[0])
}
}
func TestWriteRProxyLineWritesOneJSONLineWithOnlyItsFields(t *testing.T) {
t.Parallel()
+87 -28
View File
@@ -1,6 +1,8 @@
// Package routewatch asks a routewatch instance about client addresses, in the
// background, and keeps its answers in memory. A routewatch instance knows, for
// an address, the netblock it is announced in and the AS that originates it.
// background, and keeps its answers. A routewatch instance knows, for an
// address, the netblock it is announced in and the AS that originates it. The
// answers are kept in memory, for at most 100,000 clients and for an hour each,
// and are written to routewatch.json and read from it by the state package.
package routewatch
import (
@@ -13,6 +15,7 @@ import (
"net/http"
"net/netip"
"net/url"
"slices"
"sync"
"time"
@@ -65,9 +68,20 @@ type Route struct {
ASN uint32
}
// Answer is what routewatch answered about a client, as routewatch.json holds
// it: the netblock the client's address is announced in, none for an address
// without a live route, the AS that originates it, and when routewatch
// answered.
type Answer struct {
Client netip.Prefix `json:"client"`
Netblock netip.Prefix `json:"netblock"`
ASN uint32 `json:"asn"`
Answered time.Time `json:"answered"`
}
// Params are what New needs.
type Params struct {
// URL is where routewatch is (SWW_ROUTEWATCH_URL).
// URL is where routewatch is (SWW_WAF_ROUTEWATCH_URL).
URL string
// Now tells the time, normally time.Now in UTC.
Now func() time.Time
@@ -84,23 +98,16 @@ type Client struct {
mu sync.Mutex
// answers are by client. Each is added as it comes and never moved up,
// so that the one that came longest ago is the first dropped.
answers *simplelru.LRU[netip.Prefix, answer]
answers *simplelru.LRU[netip.Prefix, Answer]
// asking are the clients whose query is under way.
asking map[netip.Prefix]bool
// retryAt is when routewatch may be asked again after a query failed.
retryAt time.Time
}
// answer is one kept answer: the route, or why there is none, and when it came.
type answer struct {
route Route
missing Missing
came time.Time
}
// New returns a Client with no answer yet.
func New(params Params) *Client {
answers, err := simplelru.NewLRU[netip.Prefix, answer](maxAnswers, nil)
answers, err := simplelru.NewLRU[netip.Prefix, Answer](maxAnswers, nil)
if err != nil {
panic(err) // NewLRU fails only for a size below one
}
@@ -128,8 +135,12 @@ func (c *Client) Route(
now := c.params.Now()
kept, found := c.answers.Peek(client)
if found && now.Sub(kept.came) < answerKept {
return kept.route, kept.missing
if found && now.Sub(kept.Answered) < answerKept {
if !kept.Netblock.IsValid() {
return Route{}, NoRoute
}
return Route{Netblock: kept.Netblock, ASN: kept.ASN}, ""
}
if now.Before(c.retryAt) {
@@ -145,12 +156,58 @@ func (c *Client) Route(
return Route{}, NoAnswerYet
}
// Snapshot returns every answer still in use, sorted by client, as
// routewatch.json lists them.
func (c *Client) Snapshot() []Answer {
c.mu.Lock()
now := c.params.Now()
answers := make([]Answer, 0, c.answers.Len())
for _, kept := range c.answers.Values() {
if now.Sub(kept.Answered) < answerKept {
answers = append(answers, kept)
}
}
c.mu.Unlock()
slices.SortFunc(answers, func(a, b Answer) int {
return a.Client.Compare(b.Client)
})
return answers
}
// Load keeps answers, read from routewatch.json, in place of those it keeps,
// but for those that came answerKept ago or more and, past maxAnswers, those
// that came longest ago.
func (c *Client) Load(answers []Answer) {
answers = slices.Clone(answers)
slices.SortStableFunc(answers, func(a, b Answer) int {
return a.Answered.Compare(b.Answered)
})
c.mu.Lock()
defer c.mu.Unlock()
c.answers.Purge()
now := c.params.Now()
for _, kept := range answers {
if now.Sub(kept.Answered) < answerKept {
c.answers.Add(kept.Client, kept)
}
}
}
// ask asks routewatch about addr, keeps the answer as client's, and notes the
// query as no longer under way. A query that fails gives no answer, and
// routewatch is not asked again for failureKept. Only the first failure of
// those under way is logged.
func (c *Client) ask(ctx context.Context, client netip.Prefix, addr netip.Addr) {
route, missing, err := c.query(ctx, addr)
route, err := c.query(ctx, addr)
now := c.params.Now()
c.mu.Lock()
@@ -160,7 +217,9 @@ func (c *Client) ask(ctx context.Context, client netip.Prefix, addr netip.Addr)
firstFailure := err != nil && !now.Before(c.retryAt)
if err == nil {
c.answers.Add(client, answer{route: route, missing: missing, came: now})
c.answers.Add(client, Answer{
Client: client, Netblock: route.Netblock, ASN: route.ASN, Answered: now,
})
} else {
c.retryAt = now.Add(failureKept)
}
@@ -174,19 +233,19 @@ func (c *Client) ask(ctx context.Context, client netip.Prefix, addr netip.Addr)
// query asks routewatch's IP info API about addr. An answer other than 200, one
// that cannot be read, and none within queryTimeout, fail. A result with no
// netblock is an address without a live route.
func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, Missing, error) {
// netblock is an address without a live route, and gives a Route without one.
func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, error) {
ctx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
endpoint, err := url.JoinPath(c.params.URL, "api/v1/ip", addr.String())
if err != nil {
return Route{}, "", fmt.Errorf("make the URL: %w", err)
return Route{}, fmt.Errorf("make the URL: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
if err != nil {
return Route{}, "", fmt.Errorf("make the request: %w", err)
return Route{}, fmt.Errorf("make the request: %w", err)
}
req.Header.Set("Accept", "application/json")
@@ -195,7 +254,7 @@ func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, Missing, er
if err != nil {
// Do's error names the URL, which holds the client's address, which
// is not to be logged: only what went wrong is kept.
return Route{}, "", fmt.Errorf("ask routewatch: %w", errors.Unwrap(err))
return Route{}, fmt.Errorf("ask routewatch: %w", errors.Unwrap(err))
}
defer func() {
@@ -203,14 +262,14 @@ func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, Missing, er
}()
if res.StatusCode != http.StatusOK {
return Route{}, "", fmt.Errorf("%w %s", errStatus, res.Status)
return Route{}, fmt.Errorf("%w %s", errStatus, res.Status)
}
return readAnswer(io.LimitReader(res.Body, maxAnswerBytes))
}
// readAnswer reads routewatch's answer about one address.
func readAnswer(body io.Reader) (Route, Missing, error) {
func readAnswer(body io.Reader) (Route, error) {
var decoded struct {
Data struct {
Results []struct {
@@ -222,22 +281,22 @@ func readAnswer(body io.Reader) (Route, Missing, error) {
err := json.NewDecoder(body).Decode(&decoded)
if err != nil {
return Route{}, "", fmt.Errorf("read the answer: %w", err)
return Route{}, fmt.Errorf("read the answer: %w", err)
}
if len(decoded.Data.Results) == 0 {
return Route{}, "", errNoResult
return Route{}, errNoResult
}
result := decoded.Data.Results[0]
if result.Netblock == "" {
return Route{}, NoRoute, nil
return Route{}, nil
}
netblock, err := netip.ParsePrefix(result.Netblock)
if err != nil {
return Route{}, "", fmt.Errorf("%w: %q", errNotNetblock, result.Netblock)
return Route{}, fmt.Errorf("%w: %q", errNotNetblock, result.Netblock)
}
return Route{Netblock: netblock.Masked(), ASN: result.ASN}, "", nil
return Route{Netblock: netblock.Masked(), ASN: result.ASN}, nil
}
+139 -13
View File
@@ -7,10 +7,12 @@ import (
"net/http"
"net/http/httptest"
"net/netip"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"sneak.berlin/go/smallwebwaf/internal/routewatch"
@@ -113,6 +115,28 @@ func answering(body string) http.HandlerFunc {
}
}
// silentURL is where a routewatch that never answers is: a query to it waits,
// without the network, until it is abandoned. TestMain registers it with Go's
// default transport, through which the Client asks.
const silentURL = "silent://routewatch/"
func TestMain(m *testing.M) {
transport, _ := http.DefaultTransport.(*http.Transport)
transport.RegisterProtocol("silent", silentRoutewatch{})
m.Run()
}
// silentRoutewatch is the routewatch at silentURL.
type silentRoutewatch struct{}
// RoundTrip waits until req is abandoned.
func (silentRoutewatch) RoundTrip(req *http.Request) (*http.Response, error) {
<-req.Context().Done()
return nil, req.Context().Err()
}
func (f *fixture) route() (routewatch.Route, routewatch.Missing) {
addr := netip.MustParseAddr(clientAddr)
client := netip.PrefixFrom(addr, addr.BitLen())
@@ -287,20 +311,29 @@ func TestFailureIsKeptForAMinuteSoRoutewatchIsNotAskedPerRequest(t *testing.T) {
func TestHangingRoutewatchNeverDelaysAndFailsTheQuery(t *testing.T) {
t.Parallel()
f := newFixture(t, func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
// In a synctest bubble, time passes only while every goroutine in it waits,
// so Route's wait is measured exactly, however busy the host is. routewatch
// is the one at silentURL: one reached over the network would keep the
// bubble's clock from moving.
synctest.Test(t, func(t *testing.T) {
// Without newFixture's stand-in, which is reached over the network.
f := &fixture{t: t, log: &syncBuffer{}}
f.client = routewatch.New(routewatch.Params{
URL: silentURL, Now: time.Now,
ProcessLog: slog.New(slog.NewTextHandler(f.log, nil)),
})
began := time.Now()
_, missing := f.route()
if waited := time.Since(began); missing != routewatch.NoAnswerYet || waited != 0 {
t.Errorf("Route gave %q after %s while routewatch hangs, want %q at once",
missing, waited, routewatch.NoAnswerYet)
}
f.waitFor(routewatch.RequestFailed)
f.wantFailureLoggedWithoutTheClient()
})
start := time.Now()
f.route()
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("Route took %s while routewatch hangs", elapsed)
}
f.waitFor(routewatch.RequestFailed)
f.wantFailureLoggedWithoutTheClient()
}
func TestRepeatedRequestsMakeOneQueryAndTheAnswerExpires(t *testing.T) {
@@ -336,3 +369,96 @@ func TestRepeatedRequestsMakeOneQueryAndTheAnswerExpires(t *testing.T) {
t.Errorf("%d queries after the answer expired, want 2", got)
}
}
func TestSnapshotHoldsTheAnswerUntilItExpires(t *testing.T) {
t.Parallel()
f := newFixture(t, answering(routedAnswer))
f.route()
f.waitFor("")
want := []routewatch.Answer{{
Client: netip.MustParsePrefix(clientAddr + "/32"),
Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496,
Answered: f.clk.Now(),
}}
if got := f.client.Snapshot(); !slices.Equal(got, want) {
t.Errorf("snapshot %+v, want %+v", got, want)
}
f.clk.advance(time.Hour)
if got := f.client.Snapshot(); len(got) != 0 {
t.Errorf("snapshot %+v an hour after the answer, want none", got)
}
}
func TestLoadedAnswersAreUsedForAnHourFromWhenTheyCame(t *testing.T) {
t.Parallel()
f := newFixture(t, answering(routedAnswer))
now := f.clk.Now()
kept := routewatch.Answer{
Client: netip.MustParsePrefix(clientAddr + "/32"),
Netblock: netip.MustParsePrefix("198.51.100.0/24"), ASN: 64497,
Answered: now.Add(-time.Hour + time.Second),
}
stale := routewatch.Answer{
Client: netip.MustParsePrefix("192.0.2.2/32"),
Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496,
Answered: now.Add(-time.Hour),
}
f.client.Load([]routewatch.Answer{kept, stale})
if got := f.client.Snapshot(); !slices.Equal(got, []routewatch.Answer{kept}) {
t.Errorf("kept %+v, want only the answer that came less than an hour ago", got)
}
route, missing := f.route()
want := routewatch.Route{Netblock: kept.Netblock, ASN: kept.ASN}
if route != want || missing != "" {
t.Errorf("route %+v (%q), want %+v", route, missing, want)
}
if got := f.queries.Load(); got != 0 {
t.Errorf("%d queries for a client with an answer loaded, want none", got)
}
}
func TestLoadDropsTheAnswerThatCameLongestAgoFirst(t *testing.T) {
t.Parallel()
const maxAnswers = 100000
f := newFixture(t, answering(routedAnswer))
now := f.clk.Now()
// routewatch.json lists the answers by client. Here each came a
// millisecond before the one listed before it, so the last listed is
// the one that came longest ago, and the one dropped.
answers := make([]routewatch.Answer, maxAnswers+1)
addr := netip.MustParseAddr("10.0.0.0")
for i := range answers {
answers[i] = routewatch.Answer{
Client: netip.PrefixFrom(addr, addr.BitLen()),
Answered: now.Add(-time.Duration(i) * time.Millisecond),
}
addr = addr.Next()
}
f.client.Load(answers)
got := f.client.Snapshot()
if len(got) != maxAnswers {
t.Fatalf("%d answers kept, want %d", len(got), maxAnswers)
}
if got[0] != answers[0] || got[maxAnswers-1] != answers[maxAnswers-1] {
t.Errorf("answers kept from %s to %s, want from %s to %s", got[0].Client,
got[maxAnswers-1].Client, answers[0].Client, answers[maxAnswers-1].Client)
}
}
+4 -4
View File
@@ -11,16 +11,16 @@ import (
)
// errResponseTooLarge ends an app's response body that is longer than
// SWRP_MAX_RESPONSE_SIZE.
// SWW_RPROXY_MAX_RESPONSE_SIZE.
var errResponseTooLarge = errors.New(
"the response body is over SWRP_MAX_RESPONSE_SIZE")
"the response body is over SWW_RPROXY_MAX_RESPONSE_SIZE")
// requestBody is the visitor's request body. rproxy first reads up to the first
// 64 KiB of it into held, and then, on its way to the app, the transport reads
// it on a goroutine of its own.
type requestBody struct {
// body is the visitor's body, ending in an *http.MaxBytesError past
// SWRP_MAX_REQUEST_SIZE.
// SWW_RPROXY_MAX_REQUEST_SIZE.
body io.ReadCloser
rq *request
// held is the part read before the request goes to the app, which Read gives
@@ -84,7 +84,7 @@ func (b *requestBody) Close() error {
// responseBody is the app's response body on its way to the visitor.
type responseBody struct {
// body is the app's body, ending in an *http.MaxBytesError past
// SWRP_MAX_RESPONSE_SIZE.
// SWW_RPROXY_MAX_RESPONSE_SIZE.
body io.ReadCloser
rq *request
}
+8 -9
View File
@@ -105,15 +105,14 @@ func setForwardedHeaders(in, out *http.Request, peer netip.Addr, trusted bool) {
return
}
// ReverseProxy has removed Forwarded and the three set below; these are the
// other headers in which traefik tells the app about the visitor and its
// request.
for _, name := range []string{
"X-Forwarded-Port", "X-Forwarded-Server", "X-Forwarded-Uri",
"X-Forwarded-Method", "X-Forwarded-Prefix", "X-Forwarded-Tls-Client-Cert",
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip",
} {
out.Header.Del(name)
// An app behind CGI or FastCGI reads X_Forwarded_Ssl as X-Forwarded-Ssl, so
// a name is matched with each _ read as - and in any letter case.
for name := range out.Header {
spelt := strings.ToLower(strings.ReplaceAll(name, "_", "-"))
if strings.HasPrefix(spelt, "x-forwarded-") ||
spelt == "forwarded" || spelt == "x-real-ip" {
delete(out.Header, name)
}
}
out.Header.Set("X-Forwarded-For", forwardedFor)
+136 -13
View File
@@ -17,6 +17,7 @@ import (
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rproxy"
"sneak.berlin/go/smallwebwaf/internal/waf"
@@ -34,13 +35,9 @@ func announcedLength(size int) string {
func TestServerHasTheFixedLimits(t *testing.T) {
t.Parallel()
cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) {
if name == "SWRP_WAF_BACKEND" {
return nowhere, true
}
return "", false
})
cfg, err := config.RProxyFromEnvironment(configtest.Viper(map[string]string{
"SWW_RPROXY_WAF_BACKEND": nowhere,
}))
if err != nil {
t.Fatalf("settings: %v", err)
}
@@ -382,6 +379,20 @@ type deadlineRecorder struct {
// stalled, if set, sends the part of the request body that never arrives. A
// read deadline that has passed ends a read of it, as on a connection.
stalled *io.PipeWriter
// flushError, if set, is what every flush returns, and nothing is flushed.
flushError error
}
// FlushError, which http.ResponseController calls to flush, returns flushError
// if it is set, and otherwise flushes.
func (w *deadlineRecorder) FlushError() error {
if w.flushError != nil {
return w.flushError
}
w.Flush()
return nil
}
// SetReadDeadline notes deadline.
@@ -447,6 +458,45 @@ func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) {
}
}
func TestABodyLeftUnreadIsReadNoMoreOnceTheTimeToSendTheAnswerRunsOut(t *testing.T) {
t.Parallel()
standIn := startWAF(t, allow)
app := startApp(t, answerBeforeTheBody)
_, _, server := startServer(t, standIn.URL, app.URL, nil)
// The visitor sends the first part and a little more, and then nothing.
stalled, stop := io.Pipe()
t.Cleanup(func() { _ = stop.Close() })
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/",
io.MultiReader(bytes.NewReader(make([]byte, questionBodyMaxBytes+1)), stalled))
req.ContentLength = 2 * questionBodyMaxBytes
req.Header.Set(contentLengthHeader, strconv.Itoa(2*questionBodyMaxBytes))
// Every flush fails, as once the time to send the answer has run out. The
// default time limits are far longer than the test, so rproxy records no
// refusal.
w := &deadlineRecorder{
ResponseRecorder: httptest.NewRecorder(),
stalled: stop,
flushError: os.ErrDeadlineExceeded,
}
before := time.Now()
server.Handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status %d, want the app's 200", w.Code)
}
if w.readDeadline.Before(before) || w.readDeadline.After(time.Now()) {
t.Errorf("the reading of the body ends at %s, want now", w.readDeadline)
}
}
func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
t.Parallel()
@@ -648,7 +698,7 @@ func TestAVisitorGetsAnAnswerWhenTheAppAnswersAsTheTimeRunsOut(t *testing.T) {
}
// visitAsTheTimeRunsOut sends requests to an app that answers about as
// SWRP_MAX_RESPONSE_TIME_MS runs out, and checks that each is answered with the
// SWW_RPROXY_MAX_RESPONSE_TIME_MS runs out, and checks that each is answered with the
// app's 200 or a 504.
func visitAsTheTimeRunsOut(t *testing.T) {
t.Helper()
@@ -807,8 +857,10 @@ func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, greeting)
})
// The request time limit is far longer than dial's deadline, so that a busy
// host cannot use it up before the app's answer arrives.
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "3000",
maxRequestTime: "120000",
})
// Far more announced than sent, so that Go's server answers for the app
@@ -869,11 +921,11 @@ func TestAVisitorThatStopsALittleShortGetsTheAppsAnswerAtOnce(t *testing.T) {
standIn := startWAF(t, allow)
app := startApp(t, answerBeforeTheBody)
// The request time limit is longer than the 10 seconds the status and headers
// of an answer have to be sent, and than dial's deadline.
// Both time limits are far longer than dial's deadline, so that the answer
// has to come before either runs out, and a busy host cannot use them up.
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "15000",
maxResponseTime: "300",
maxRequestTime: "120000",
maxResponseTime: "60000",
})
conn := dial(t, addr)
@@ -910,6 +962,77 @@ func TestAVisitorThatStopsALittleShortIsReadUntilTheRequestTimeLimit(t *testing.
}
}
func TestAVisitorThatStopsALittleShortHasTheConnectionClosedWhenTheAnswerIsCutOff(
t *testing.T,
) {
t.Parallel()
// rproxy passes on a part sent at once well within this limit, even on a busy
// host.
const limit = 2 * time.Second
// The app sends the parts in atOnce with its status and headers. It sends the
// parts in late only once rproxy has ended the request to it, which rproxy
// does when it cuts off the answer, so that those never reach the visitor.
for _, tc := range []struct {
name string
atOnce, late []string
}{
{"with the body in one part", nil, []string{greeting}},
{"with the body in two parts", []string{greeting[:5]}, []string{greeting[5:]}},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
standIn := startWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
rc := http.NewResponseController(w)
_ = rc.EnableFullDuplex()
w.Header().Set(contentLengthHeader, strconv.Itoa(len(greeting)))
for _, part := range tc.atOnce {
_, _ = io.WriteString(w, part)
}
_ = rc.Flush()
// The request's body ends when rproxy ends the request to the app.
_, _ = io.Copy(io.Discard, r.Body)
for _, part := range tc.late {
_, _ = io.WriteString(w, part)
_ = rc.Flush()
}
})
// The request time limit is far longer than dial's deadline.
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "120000",
maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10),
})
conn := dial(t, addr)
sendAllButALittle(t, conn)
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatalf("read the answer: %v", err)
}
// If the connection stays open, dial's deadline ends this read with a
// timeout.
body, err := io.ReadAll(res.Body)
_ = res.Body.Close()
want := strings.Join(tc.atOnce, "")
if string(body) != want || !errors.Is(err, io.ErrUnexpectedEOF) {
t.Errorf("read %q (%v), want %q and the connection closed", body, err, want)
}
})
}
}
func TestAVisitorThatStopsSendingWhileTheAppWorksGetsNoAnswer(t *testing.T) {
t.Parallel()
+59 -30
View File
@@ -34,14 +34,14 @@ const plainText = "text/plain; charset=utf-8"
// minSendTime is the least time to send an answer rproxy gives itself, or the
// status and headers of the app's answer. A shorter time can run out before the
// first byte is written, on a busy host or as SWRP_MAX_RESPONSE_TIME_MS runs out,
// first byte is written, on a busy host or as SWW_RPROXY_MAX_RESPONSE_TIME_MS runs out,
// and the visitor then gets a closed connection and no answer.
const minSendTime = 10 * time.Second
// errResponseTooLate refuses an app's answer that arrives once
// SWRP_MAX_RESPONSE_TIME_MS has run out.
// SWW_RPROXY_MAX_RESPONSE_TIME_MS has run out.
var errResponseTooLate = errors.New(
"the answer arrived after SWRP_MAX_RESPONSE_TIME_MS ran out")
"the answer arrived after SWW_RPROXY_MAX_RESPONSE_TIME_MS ran out")
// request is one request on its way through rproxy, from the moment its headers
// have been read to its log line and report.
@@ -61,10 +61,10 @@ type request struct {
start time.Time
// disposition is what is done with the request: waf's valid disposition,
// or SWRP_DEFAULT_DISPOSITION. It is "" when neither gave one.
// or SWW_RPROXY_DEFAULT_DISPOSITION. It is "" when neither gave one.
disposition string
// answered is true when waf gave disposition, which makes a report due,
// and byDefault when SWRP_DEFAULT_DISPOSITION did.
// and byDefault when SWW_RPROXY_DEFAULT_DISPOSITION did.
answered bool
byDefault bool
// asn and country are waf's X-Client-ASN and X-Client-Country, for the
@@ -127,10 +127,10 @@ func (rq *request) answerHealth() {
}
// readFirstPartOfBody reads up to the first 64 KiB of the body into memory,
// within SWRP_MAX_REQUEST_TIME_MS, and reports whether the request goes on. A
// within SWW_RPROXY_MAX_REQUEST_TIME_MS, and reports whether the request goes on. A
// visitor too slow to send it is answered 408. For a malformed chunk, or a
// visitor that went away or stopped its sending side, the connection is closed
// without an answer. A body over SWRP_MAX_REQUEST_SIZE goes on with the part
// without an answer. A body over SWW_RPROXY_MAX_REQUEST_SIZE goes on with the part
// read, so that waf is asked first; passToApp then refuses it.
func (rq *request) readFirstPartOfBody() bool {
if rq.body == nil {
@@ -198,7 +198,7 @@ func (rq *request) forward(ctx context.Context) {
}
// rewrite makes the request the app receives: the visitor's request, unchanged,
// sent to SWRP_UPSTREAM_URL, with the forwarded headers and the request's id set,
// sent to SWW_RPROXY_UPSTREAM_URL, with the forwarded headers and the request's id set,
// without any X-Client-ASN or X-Client-Country the visitor sent, and with
// waf's.
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
@@ -247,7 +247,7 @@ func (rq *request) modifyResponse(res *http.Response) error {
requestEnd := rq.endRequest()
// An answer that arrives once SWRP_MAX_RESPONSE_TIME_MS has run out is not
// An answer that arrives once SWW_RPROXY_MAX_RESPONSE_TIME_MS has run out is not
// passed on: the visitor is answered 504.
limit := rq.h.config.MaxResponseTime
if limit > 0 && !time.Now().Before(requestEnd.Add(limit)) {
@@ -272,6 +272,10 @@ func (rq *request) modifyResponse(res *http.Response) error {
rq.closeAfterAnswer()
// An answer that arrives before the app has been sent the whole request ends
// the request, and SWW_RPROXY_MAX_RESPONSE_TIME_MS starts.
rq.startResponseTimer(requestEnd)
res.Body = &responseBody{body: limitBody(res.Body, maxBytes), rq: rq}
rq.out.afterStatus = rq.setResponseDeadline
@@ -357,7 +361,7 @@ func (rq *request) closeAfterAnswer() {
// prepareOwnAnswer readies the connection for an answer rproxy gives itself: the
// reading of a body that has not arrived whole ends at deadline, and the answer
// has SWRP_MAX_RESPONSE_TIME_MS from now to reach the visitor, even when the app
// has SWW_RPROXY_MAX_RESPONSE_TIME_MS from now to reach the visitor, even when the app
// used that time up first, and at least minSendTime.
func (rq *request) prepareOwnAnswer(deadline time.Time) {
rq.stopReadingBody(deadline)
@@ -384,8 +388,23 @@ func (rq *request) refuse(status int) {
func (rq *request) finish(ctx context.Context) {
rq.stopTimers()
if rq.refused.Load() == 0 {
rq.stopPassingOnBody()
// After a cut-off answer, the reading of a body that has not arrived whole
// ends now. Go's server would otherwise keep the connection open while it
// reads the rest. modifyResponse sets afterStatus only when it passes the
// app's answer on. Any refusal after that has cut the answer off, since a
// refusal ends the request to the app. Any answer is cut off when the time to
// send it runs out: a flush then fails, and every flush after a failed one
// fails too. ReverseProxy ignores the failure, but panics if it was still
// waiting for more of the app's answer; finish runs either way. Before a
// status, a flush would send a 200; after a 101, ReverseProxy has taken over
// the connection.
switch {
case rq.out.afterStatus != nil && rq.refused.Load() != 0,
rq.out.status != 0 && rq.out.status != http.StatusSwitchingProtocols &&
errors.Is(rq.rc.Flush(), os.ErrDeadlineExceeded):
rq.stopPassingOnBody(time.Now())
case rq.refused.Load() == 0:
rq.stopPassingOnBody(rq.requestDeadline())
}
requestEnd := rq.endRequest()
@@ -451,7 +470,7 @@ func (rq *request) bytesEachWay() (int64, int64) {
}
// requestDeadline is when the visitor must have sent its whole request, or zero
// when SWRP_MAX_REQUEST_TIME_MS is off.
// when SWW_RPROXY_MAX_REQUEST_TIME_MS is off.
func (rq *request) requestDeadline() time.Time {
limit := rq.h.config.MaxRequestTime
if limit == 0 {
@@ -473,10 +492,10 @@ func (rq *request) stopReadingBody(deadline time.Time) {
}
// stopPassingOnBody ends the transport's reading of a visitor's body that has
// not arrived whole, and lets Go's server read the rest until
// SWRP_MAX_REQUEST_TIME_MS runs out. Go's server reads it once the handler
// returns, but with no deadline at all if another read of it is still waiting.
func (rq *request) stopPassingOnBody() {
// not arrived whole, and lets Go's server read the rest until deadline. Go's
// server reads it once the handler returns, but with no deadline at all if
// another read of it is still waiting.
func (rq *request) stopPassingOnBody(deadline time.Time) {
if rq.body == nil || rq.body.received.Load() {
return
}
@@ -489,10 +508,10 @@ func (rq *request) stopPassingOnBody() {
rq.body.stopped = true
rq.body.mu.Unlock()
rq.stopReadingBody(rq.requestDeadline())
rq.stopReadingBody(deadline)
}
// startRequestTimer starts SWRP_MAX_REQUEST_TIME_MS, which runs from the
// startRequestTimer starts SWW_RPROXY_MAX_REQUEST_TIME_MS, which runs from the
// request's headers being read until the app has been sent the whole request.
func (rq *request) startRequestTimer() {
deadline := rq.requestDeadline()
@@ -506,7 +525,7 @@ func (rq *request) startRequestTimer() {
rq.requestTimer = time.AfterFunc(time.Until(deadline), rq.requestTimedOut)
}
// requestTimedOut is called when SWRP_MAX_REQUEST_TIME_MS runs out while the
// requestTimedOut is called when SWW_RPROXY_MAX_REQUEST_TIME_MS runs out while the
// request is still on its way to the app. The answer names the side rproxy was
// waiting on at that moment: 408 when it was waiting for the visitor to send more
// of its body, 504 when it was waiting for the app to be reached or to take what
@@ -533,7 +552,7 @@ func (rq *request) requestTimedOut() {
}
// wroteRequest is called once the app has been sent the whole request:
// SWRP_MAX_REQUEST_TIME_MS ends and SWRP_MAX_RESPONSE_TIME_MS starts.
// SWW_RPROXY_MAX_REQUEST_TIME_MS ends and SWW_RPROXY_MAX_RESPONSE_TIME_MS starts.
func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
if info.Err != nil {
return // the transport gives up, or tries again
@@ -542,22 +561,32 @@ func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
requestEnd := rq.endRequest()
rq.mu.Lock()
defer rq.mu.Unlock()
stopTimer(rq.requestTimer)
rq.mu.Unlock()
if rq.timersStopped {
rq.startResponseTimer(requestEnd)
}
// startResponseTimer starts SWW_RPROXY_MAX_RESPONSE_TIME_MS, which runs from
// requestEnd, unless it has started already.
func (rq *request) startResponseTimer(requestEnd time.Time) {
limit := rq.h.config.MaxResponseTime
if limit == 0 {
return
}
stopTimer(rq.requestTimer)
rq.mu.Lock()
defer rq.mu.Unlock()
limit := rq.h.config.MaxResponseTime
if limit > 0 {
rq.responseTimer = time.AfterFunc(time.Until(requestEnd.Add(limit)),
rq.responseTimedOut)
if rq.timersStopped || rq.responseTimer != nil {
return
}
rq.responseTimer = time.AfterFunc(time.Until(requestEnd.Add(limit)),
rq.responseTimedOut)
}
// responseTimedOut is called when SWRP_MAX_RESPONSE_TIME_MS runs out before the
// responseTimedOut is called when SWW_RPROXY_MAX_RESPONSE_TIME_MS runs out before the
// app has sent its whole answer.
func (rq *request) responseTimedOut() {
rq.mu.Lock()
@@ -575,7 +604,7 @@ func (rq *request) responseReceived() {
// setResponseDeadline sends the status and headers of the app's answer, with
// at least minSendTime, before ReverseProxy reads the answer's body, and then
// the rest has until SWRP_MAX_RESPONSE_TIME_MS after the end of the request.
// the rest has until SWW_RPROXY_MAX_RESPONSE_TIME_MS after the end of the request.
func (rq *request) setResponseDeadline() {
limit := rq.h.config.MaxResponseTime
if limit == 0 {
+7 -7
View File
@@ -1,11 +1,11 @@
// Package rproxy is the reverse proxy in front of one app that asks waf
// about each request. It passes a request waf allows to the app and the
// app's answer back, within the size and time limits. Any other answer from
// waf goes to the visitor as waf gave it. SWRP_DEFAULT_DISPOSITION
// waf goes to the visitor as waf gave it. SWW_RPROXY_DEFAULT_DISPOSITION
// decides only when waf cannot be connected to, does not answer at all, or
// does not answer within SWRP_TIMEOUT_MS. rproxy itself refuses a request with a
// does not answer within SWW_RPROXY_TIMEOUT_MS. rproxy itself refuses a request with a
// method it does not pass on, or one it cannot put to waf as the visitor
// sent it. It keeps nothing between requests and never touches disk. "rproxy" in
// sent it. It keeps nothing between requests and writes nothing to disk. "rproxy" in
// SPEC.md says what it does.
package rproxy
@@ -73,7 +73,7 @@ type Server struct {
// New returns the server `sww rproxy` runs. Go's server itself refuses
// a request line and headers over requestHeaderMaxBytes, with 431, closes a
// connection idle for idleTimeout, and applies SWRP_MAX_REQUEST_TIME_MS while
// connection idle for idleTimeout, and applies SWW_RPROXY_MAX_REQUEST_TIME_MS while
// the headers arrive. The handler applies the time and size limits from then
// on.
func New(params Params) *Server {
@@ -105,7 +105,7 @@ func New(params Params) *Server {
}
// WaitForReports returns once the reports being sent have gone or failed,
// which SWRP_TIMEOUT_MS bounds. Any report after this call is dropped: that of
// which SWW_RPROXY_TIMEOUT_MS bounds. Any report after this call is dropped: that of
// a WebSocket that closes while rproxy stops, for one.
func (s *Server) WaitForReports() {
s.h.reportsMu.Lock()
@@ -214,8 +214,8 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// passToApp passes the request on to the app, unless a limit or its path keeps
// it from the app. waf answers a path under ownEndpointPrefix itself, so
// such a request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the
// request's own context.
// such a request gets here only when SWW_RPROXY_DEFAULT_DISPOSITION allowed it.
// ctx is the request's own context.
func (rq *request) passToApp(ctx context.Context) {
maxBytes := rq.h.config.MaxRequestSize
+22 -15
View File
@@ -19,6 +19,7 @@ import (
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rproxy"
"sneak.berlin/go/smallwebwaf/internal/waf"
@@ -43,13 +44,13 @@ const (
// The settings the tests set.
const (
timeoutSetting = "SWRP_TIMEOUT_MS"
defaultDisposition = "SWRP_DEFAULT_DISPOSITION"
trustedProxies = "SWRP_TRUSTED_PROXIES"
maxRequestSize = "SWRP_MAX_REQUEST_SIZE"
maxResponseSize = "SWRP_MAX_RESPONSE_SIZE"
maxRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
maxResponseTime = "SWRP_MAX_RESPONSE_TIME_MS"
timeoutSetting = "SWW_RPROXY_TIMEOUT_MS"
defaultDisposition = "SWW_RPROXY_DEFAULT_DISPOSITION"
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
maxRequestSize = "SWW_RPROXY_MAX_REQUEST_SIZE"
maxResponseSize = "SWW_RPROXY_MAX_RESPONSE_SIZE"
maxRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
maxResponseTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
)
// The headers the tests set and look at.
@@ -354,15 +355,11 @@ func startServerLogging(
t.Helper()
settings := map[string]string{
"SWRP_WAF_BACKEND": backend, "SWRP_UPSTREAM_URL": appURL,
"SWW_RPROXY_WAF_BACKEND": backend, "SWW_RPROXY_UPSTREAM_URL": appURL,
}
maps.Copy(settings, env)
cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) {
value, ok := settings[name]
return value, ok
})
cfg, err := config.RProxyFromEnvironment(configtest.Viper(settings))
if err != nil {
t.Fatalf("settings %v: %v", settings, err)
}
@@ -559,6 +556,9 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
"X-Forwarded-Port", "X-Forwarded-Server", "X-Forwarded-Uri",
"X-Forwarded-Method", "X-Forwarded-Prefix", "X-Forwarded-Tls-Client-Cert",
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip",
"X-Forwarded-Scheme", "X-Forwarded-Ssl", "X-Forwarded-Protocol",
"X-Forwarded-Path", "X-Forwarded-Client-Cert",
"X_Forwarded_Ssl", "X_Forwarded_Proto", "X_Real_IP",
}
standIn := startWAF(t, allow)
@@ -582,6 +582,9 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
req.Header.Set(name, "claimed")
}
// A made-up name, sent in lower case.
req.Header["x-forwarded-made-up"] = []string{"claimed"}
wantGreeting(t, visit(t, req))
header := <-seen
@@ -596,7 +599,7 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
}
}
for _, name := range removed {
for _, name := range append(removed, "X-Forwarded-Made-Up") {
if got := header.Values(name); len(got) != 0 {
t.Errorf("the app saw %s %q, want none", name, got)
}
@@ -622,6 +625,8 @@ func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
"X-Forwarded-Host": "public.example",
forwardedProtoHeader: "https",
"X-Real-Ip": "198.51.100.1",
"X-Forwarded-Ssl": "on",
"X_Forwarded_Ssl": "on",
}
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
@@ -1023,7 +1028,9 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write(make([]byte, size))
})
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
addr, _ := startRProxy(t, standIn.URL, nowhere, map[string]string{
timeoutSetting: "60000",
})
got := get(t, addr, "/")
if got.status != http.StatusInternalServerError || len(got.body) != size {
+5 -5
View File
@@ -65,9 +65,9 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
rq.answer(http.StatusBadGateway)
default:
// waf cannot be connected to, does not answer at all, or does not answer
// within SWRP_TIMEOUT_MS. A request that keeps waf busy past
// SWRP_TIMEOUT_MS, like any request while waf is overloaded, gets
// SWRP_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
// within SWW_RPROXY_TIMEOUT_MS. A request that keeps waf busy past
// SWW_RPROXY_TIMEOUT_MS, like any request while waf is overloaded, gets
// SWW_RPROXY_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
// whenever waf is slow. Making such requests hard to send is
// https://git.eeqj.de/sneak/smallwebwaf/issues/182.
rq.h.processLog.Warn("waf gave no answer", "error", err.Error())
@@ -84,7 +84,7 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
}
// ask sends waf the question, and returns its answer, which must have come
// whole within SWRP_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
// whole within SWW_RPROXY_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
// away.
func (rq *request) ask(ctx context.Context) (*wafAnswer, error) {
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
@@ -228,7 +228,7 @@ func (h *handler) sendReport(ctx context.Context, report *waf.Report) {
}
// postReport posts report to waf, and waits for its answer for up to
// SWRP_TIMEOUT_MS.
// SWW_RPROXY_TIMEOUT_MS.
func (h *handler) postReport(ctx context.Context, report *waf.Report) error {
body, err := json.Marshal(report)
if err != nil {
+6 -6
View File
@@ -1,5 +1,5 @@
// Package rules reads the rule files: the plain text files in
// SWW_RULES_DIR, one rule to a line, that each request is checked
// SWW_WAF_RULES_DIR, one rule to a line, that each request is checked
// against, as the "Rule files" section of SPEC.md describes. They are read
// at start, and again once the directory has had no change for a short
// time after one is edited, added or removed.
@@ -40,7 +40,7 @@ const (
// extension ends the name of every rule file.
const extension = ".rules"
// quietTime is how long SWW_RULES_DIR must go without a change before
// quietTime is how long SWW_WAF_RULES_DIR must go without a change before
// the rule files are read again, so that a file still being written, such
// as one saved in place, appended to or copied in with scp, is read only
// once whole.
@@ -100,9 +100,9 @@ type Rule struct {
// Params are what Load needs.
type Params struct {
// Dir is the directory of the rule files (SWW_RULES_DIR).
// Dir is the directory of the rule files (SWW_WAF_RULES_DIR).
Dir string
// Enabled is SWW_RULES_ENABLED: while it is false, no file is read
// Enabled is SWW_WAF_RULES_ENABLED: while it is false, no file is read
// and no rule loaded.
Enabled bool
// ProcessLog receives how many rules were read, and the error in a
@@ -271,7 +271,7 @@ func (f *Files) logRead(count int) {
func read(dir string) ([]Rule, string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, dir, fmt.Errorf("SWW_RULES_DIR cannot be read: %w", err)
return nil, dir, fmt.Errorf("SWW_WAF_RULES_DIR cannot be read: %w", err)
}
var rules []Rule
@@ -299,7 +299,7 @@ func read(dir string) ([]Rule, string, error) {
// readFile appends the rules of the rule file at path to rules. places
// are where each id read so far is, and gain those of the file.
func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) {
data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWW_RULES_DIR
data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWW_WAF_RULES_DIR
if err != nil {
return nil, err
}
+2 -2
View File
@@ -298,7 +298,7 @@ func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
dir := filepath.Join(t.TempDir(), "rules.d")
wantRefused(t, dir, "SWW_RULES_DIR cannot be read: open "+dir+
wantRefused(t, dir, "SWW_WAF_RULES_DIR cannot be read: open "+dir+
": no such file or directory")
}
@@ -321,7 +321,7 @@ func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
func TestRuleFilesOffReadNothing(t *testing.T) {
t.Parallel()
// SWW_RULES_DIR does not exist, which would stop the start.
// SWW_WAF_RULES_DIR does not exist, which would stop the start.
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
params.Enabled = false
+467 -160
View File
@@ -1,13 +1,23 @@
// Package state keeps sww's state in JSON files in
// SWW_STATE_DIR, as the "Persistent state" section of SPEC.md requires
// and docs/state-files.md lists them:
// bans.json holds the bans, clients.json each client's counters and
// history, lookups.json GeoJS's answers, reputation.json the last try and
// last good copy of each list fetched from a URL, the DNSBL zones'
// verdicts, and AbuseIPDB's scores and checks spent, and alerts.json the
// cooldowns, the hour under way, the alerts waiting for each destination
// and the anomaly counters. Load
// reads them at start, Watch takes in an admin's edit of one while
// Package state keeps sww's state in JSON files, as the "Persistent
// state" section of SPEC.md requires and docs/state-files.md lists them.
//
// The state files are in SWW_WAF_STATE_DIR:
// - bans.json holds the bans;
// - clients.json holds each client's counters and history;
// - abuseipdb-checks-spent.json holds the AbuseIPDB checks spent today;
// - alerts.json holds the cooldowns, the hour under way, the alerts
// waiting for each destination and the anomaly counters.
//
// The cache files are in SWW_WAF_CACHE_DIR. Each keeps answers from an
// outside service, which are fetched again when the file is lost:
// - lookups.json holds GeoJS's answers;
// - lists.json holds the last try and last good copy of each list
// fetched from a URL;
// - dnsbl.json holds the DNSBL zones' verdicts;
// - abuseipdb-scores.json holds AbuseIPDB's scores;
// - routewatch.json holds routewatch's answers.
//
// Load reads them at start, Watch takes in an admin's edit of one while
// sww runs, and Run and WriteAll write them. The disk is read and
// written outside the parts' locks, which are held only to take a
// snapshot or to put in what a file holds, so that no request waits on
@@ -31,6 +41,7 @@ import (
"sync"
"time"
"cloud.google.com/go/civil"
"github.com/fsnotify/fsnotify"
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/anomaly"
@@ -39,6 +50,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/metrics"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/routewatch"
)
// version is the version of the files' format, the only one read.
@@ -48,15 +60,42 @@ const version = 1
// hold visitors' addresses.
const fileMode = 0o600
// The state files' names.
// The state files' names, in Dir.
const (
bansJSON = "bans.json"
clientsJSON = "clients.json"
lookupsJSON = "lookups.json"
reputationJSON = "reputation.json"
alertsJSON = "alerts.json"
bansJSON = "bans.json"
clientsJSON = "clients.json"
abuseIPDBChecksSpentJSON = "abuseipdb-checks-spent.json"
alertsJSON = "alerts.json"
)
// The cache files' names, in CacheDir.
const (
lookupsJSON = "lookups.json"
listsJSON = "lists.json"
dnsblJSON = "dnsbl.json"
abuseIPDBScoresJSON = "abuseipdb-scores.json"
routeWatchJSON = "routewatch.json"
)
// fileNames returns the names of the state files, then those of the
// cache files, in the order they are written.
func fileNames() []string {
return []string{
bansJSON, clientsJSON, abuseIPDBChecksSpentJSON, alertsJSON,
lookupsJSON, listsJSON, dnsblJSON, abuseIPDBScoresJSON, routeWatchJSON,
}
}
// isCacheFile reports whether name is the name of a cache file.
func isCacheFile(name string) bool {
switch name {
case lookupsJSON, listsJSON, dnsblJSON, abuseIPDBScoresJSON, routeWatchJSON:
return true
default:
return false
}
}
var (
errVersion = errors.New("unknown version")
// errMissing is for an entry without a field it needs.
@@ -72,24 +111,29 @@ var (
// Params are what Load needs.
type Params struct {
// Dir is the directory of the state files (SWW_STATE_DIR).
Dir string
// Dir is the directory of the state files (SWW_WAF_STATE_DIR), and
// CacheDir that of the cache files (SWW_WAF_CACHE_DIR).
Dir string
CacheDir string
// WriteDelay is how long after a ban is made bans.json is written
// (SWW_STATE_WRITE_DELAY), and CounterInterval how often every file
// is (SWW_STATE_COUNTER_INTERVAL).
// (SWW_WAF_STATE_WRITE_DELAY), and CounterInterval how often every file
// is (SWW_WAF_STATE_COUNTER_INTERVAL).
WriteDelay time.Duration
CounterInterval time.Duration
// Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, Alerts and Anomalies
// hold the state. Alerts also receive a file_error alert for an edit set
// aside, and for a write that fails while sww runs.
Ledger *bans.Ledger
Limiter *ratelimit.Limiter
GeoJS *lookup.GeoJS
Lists *reputation.Lists
DNSBL *reputation.DNSBL
AbuseIPDB *reputation.AbuseIPDB
Alerts *alerts.Queue
Anomalies *anomaly.Counters
// Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, RouteWatch, Alerts and
// Anomalies hold the state. RouteWatch is nil while SWW_WAF_ROUTEWATCH_URL is
// unset, and routewatch.json then holds no answers. Alerts also receive a
// file_error alert for an edit set aside, and for a write that fails while
// sww runs.
Ledger *bans.Ledger
Limiter *ratelimit.Limiter
GeoJS *lookup.GeoJS
Lists *reputation.Lists
DNSBL *reputation.DNSBL
AbuseIPDB *reputation.AbuseIPDB
RouteWatch *routewatch.Client
Alerts *alerts.Queue
Anomalies *anomaly.Counters
// Now tells the time by which the counters' buckets run out, normally
// time.Now in UTC.
Now func() time.Time
@@ -101,7 +145,7 @@ type Params struct {
Metrics *metrics.Metrics
}
// Files are the state files of a running sww.
// Files are the state and cache files of a running sww.
type Files struct {
params Params
@@ -147,13 +191,38 @@ type lookupsFile struct {
Lookups []lookup.Answer `json:"lookups"`
}
// reputationFile is reputation.json, indented for an admin to read and
// edit, so that each line of a list's copy is on a line of its own.
type reputationFile struct {
Version int `json:"version"`
Lists []reputation.List `json:"lists"`
Verdicts []reputation.Verdict `json:"verdicts"`
AbuseIPDB reputation.Checks `json:"abuseipdb"`
// abuseIPDBChecksSpentFile is abuseipdb-checks-spent.json, indented for an
// admin to read and edit.
type abuseIPDBChecksSpentFile struct {
Version int `json:"version"`
Day civil.Date `json:"day,omitzero"`
Spent int `json:"spent"`
}
// listsFile is lists.json, indented for an admin to read and edit, so
// that each line of a list's copy is on a line of its own.
type listsFile struct {
Version int `json:"version"`
Lists []reputation.List `json:"lists"`
}
// dnsblFile is dnsbl.json, with each verdict on a line of its own.
type dnsblFile struct {
Version int `json:"version"`
Verdicts []reputation.Verdict `json:"verdicts"`
}
// abuseIPDBScoresFile is abuseipdb-scores.json, with each score on a line
// of its own.
type abuseIPDBScoresFile struct {
Version int `json:"version"`
Scores []reputation.Score `json:"scores"`
}
// routeWatchFile is routewatch.json, with each answer on a line of its own.
type routeWatchFile struct {
Version int `json:"version"`
Answers []routewatch.Answer `json:"answers"`
}
// alertsFile is alerts.json, indented for an admin to read and edit.
@@ -178,34 +247,45 @@ type stateFile interface {
toUTC()
}
// Load checks that files can be written in Dir, and reads the state files
// in it into the parts of Params that hold the state. A missing file is
// empty state, as on a first start. A file that does not parse, has an
// unknown version, or has an entry without a field it needs, is an error
// that names the file and, where the JSON decoder tells it, the line and
// column, or else the entry.
// Load checks that files can be written in Dir and CacheDir, and reads the
// files in them into the parts of Params that hold the state. A missing
// file is empty state, as on a first start. A state file that does not
// parse, has an unknown version, or has an entry without a field it
// needs, is an error that names the file and, where the JSON decoder
// tells it, the line and column, or else the entry. A cache file is never
// an error, as readCache describes.
func Load(params Params) (*Files, error) {
err := checkWritable(params.Dir)
if err != nil {
return nil, fmt.Errorf("SWW_STATE_DIR cannot be written: %w", err)
return nil, fmt.Errorf("SWW_WAF_STATE_DIR cannot be written: %w", err)
}
err = checkWritable(params.CacheDir)
if err != nil {
return nil, fmt.Errorf("SWW_WAF_CACHE_DIR cannot be written: %w", err)
}
f := &Files{params: params, sums: map[string][sha256.Size]byte{}}
bansRead, bansErr := f.read(bansJSON)
clientsRead, clientsErr := f.read(clientsJSON)
lookupsRead, lookupsErr := f.read(lookupsJSON)
reputationRead, reputationErr := f.read(reputationJSON)
_, checksErr := f.read(abuseIPDBChecksSpentJSON)
alertsRead, alertsErr := f.read(alertsJSON)
err = errors.Join(bansErr, clientsErr, lookupsErr, reputationErr, alertsErr)
err = errors.Join(bansErr, clientsErr, checksErr, alertsErr)
if err != nil {
return nil, err
}
params.ProcessLog.Info("read the state files", "directory", params.Dir,
"bans", bansRead, "clients", clientsRead, "lookups", lookupsRead,
"lists", reputationRead, "alerts_waiting", alertsRead)
lookupsRead := f.readCache(lookupsJSON)
listsRead := f.readCache(listsJSON)
f.readCache(dnsblJSON)
f.readCache(abuseIPDBScoresJSON)
f.readCache(routeWatchJSON)
params.ProcessLog.Info("read the state and cache files", "directory", params.Dir,
"cache_directory", params.CacheDir, "bans", bansRead, "clients", clientsRead,
"lookups", lookupsRead, "lists", listsRead, "alerts_waiting", alertsRead)
return f, nil
}
@@ -234,69 +314,101 @@ func (f *Files) Run(ctx context.Context) {
f.logFailure(bansJSON, f.writeFile(bansJSON))
case <-interval.C:
for _, name := range []string{
bansJSON, clientsJSON, lookupsJSON, reputationJSON, alertsJSON,
} {
for _, name := range fileNames() {
f.logFailure(name, f.writeFile(name))
}
}
}
}
// WriteAll writes every state file, as sww stops. A file that
// WriteAll writes every state and cache file, as sww stops. A file that
// fails does not keep the others from being written.
func (f *Files) WriteAll() error {
return errors.Join(f.writeFile(bansJSON), f.writeFile(clientsJSON),
f.writeFile(lookupsJSON), f.writeFile(reputationJSON), f.writeFile(alertsJSON))
}
names := fileNames()
errs := make([]error, 0, len(names))
// Watch watches Dir until ctx is done, and takes in an admin's edit of a
// state file as soon as it is saved: what the file holds replaces what
// sww held for it. An edit that does not parse is left for the
// file's next write, which sets it aside, since a file can be read while
// an editor is still writing it. If Dir cannot be watched, that is
// logged, and an edit is taken in only before its file is written.
func (f *Files) Watch(ctx context.Context) {
watcher, err := fsnotify.NewWatcher()
if err == nil {
defer func() {
_ = watcher.Close()
}()
err = watcher.Add(f.params.Dir)
for _, name := range names {
errs = append(errs, f.writeFile(name))
}
return errors.Join(errs...)
}
// Watch watches Dir and CacheDir until ctx is done, and takes in an
// admin's edit of a file as soon as it is saved: what the file holds
// replaces what sww held for it. An edit that does not parse is left for
// the file's next write, which sets it aside, since a file can be read
// while an editor is still writing it. A directory that cannot be watched
// is logged, and the other is still watched. An edit in a directory not
// watched is taken in only before its file is written.
func (f *Files) Watch(ctx context.Context) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
f.params.ProcessLog.Error("cannot watch the state files for edits",
f.params.ProcessLog.Error("cannot watch the state and cache files for edits",
"error", err.Error())
return
}
f.params.ProcessLog.Info("watching the state files for edits",
"directory", f.params.Dir)
defer func() {
_ = watcher.Close()
}()
var watched []string
for _, dir := range []struct{ path, failed string }{
{f.params.Dir, "cannot watch the state files for edits"},
{f.params.CacheDir, "cannot watch the cache files for edits"},
} {
err = watcher.Add(dir.path)
if err != nil {
f.params.ProcessLog.Error(dir.failed, "directory", dir.path,
"error", err.Error())
continue
}
watched = append(watched, dir.path)
}
watching := "watching the state and cache files for edits"
switch {
case len(watched) == 0:
return
case len(watched) == 1 && watched[0] == f.params.Dir:
watching = "watching the state files for edits"
case len(watched) == 1:
watching = "watching the cache files for edits"
}
f.params.ProcessLog.Info(watching, "directories", watched)
for {
select {
case <-ctx.Done():
return
case event := <-watcher.Events:
switch name := filepath.Base(event.Name); name {
case bansJSON, clientsJSON, lookupsJSON, reputationJSON, alertsJSON:
name := filepath.Base(event.Name)
if slices.Contains(fileNames(), name) &&
filepath.Join(f.dir(name), name) == event.Name {
f.fileChanged(name)
}
case err = <-watcher.Errors:
f.params.ProcessLog.Warn("watching the state files failed",
f.params.ProcessLog.Warn("watching the state and cache files failed",
"error", err.Error())
}
}
}
// logFailure logs a write of the state file name that failed, and raises
// logFailure logs a write of the file name that failed, and raises
// a file_error alert for it.
func (f *Files) logFailure(name string, err error) {
if err != nil {
const failed = "writing the state files failed"
failed := "writing the state files failed"
if isCacheFile(name) {
failed = "writing the cache files failed"
}
// Raised before it is logged, so that the alert is there once the
// log line is.
@@ -304,14 +416,14 @@ func (f *Files) logFailure(name string, err error) {
Event: alerts.EventFileError,
Reason: failed,
Detail: map[string]any{
"file": filepath.Join(f.params.Dir, name), "error": err.Error(),
"file": filepath.Join(f.dir(name), name), "error": err.Error(),
},
})
f.params.ProcessLog.Error(failed, "error", err.Error())
}
}
// fileChanged takes in what the state file name holds, as Watch sees it
// fileChanged takes in what the file name holds, as Watch sees it
// change, if that is an edit made since sww last read or wrote
// the file. A file that cannot be read or does not parse is left for its
// next write.
@@ -327,7 +439,7 @@ func (f *Files) fileChanged(name string) {
_ = f.takeInEdit(name, data)
}
// takeInEdit takes in data, an edit of the state file name, as takeIn
// takeInEdit takes in data, an edit of the file name, as takeIn
// does, and counts and logs it. Every edit taken in while sww
// runs, by Watch or by a write, is taken in here. An edit that does not
// parse is neither counted nor logged, and takeIn's error returned.
@@ -337,17 +449,31 @@ func (f *Files) takeInEdit(name string, data []byte) error {
return err
}
tookIn := "took in an edit of a state file"
if isCacheFile(name) {
tookIn = "took in an edit of a cache file"
}
// Counted before it is logged, so that the count is there once the
// log line is.
f.params.Metrics.StateFileEditTakenIn(name)
f.params.ProcessLog.Info("took in an edit of a state file",
"file", filepath.Join(f.params.Dir, name))
f.params.ProcessLog.Info(tookIn, "file", filepath.Join(f.dir(name), name))
return nil
}
// read takes in the state file name at start, and returns how many
// entries it holds. A missing file holds none.
// dir returns the directory of the file name: CacheDir for a cache file,
// and Dir for a state file.
func (f *Files) dir(name string) string {
if isCacheFile(name) {
return f.params.CacheDir
}
return f.params.Dir
}
// read takes in the file name at start, and returns how many entries it
// holds. A missing file holds none.
func (f *Files) read(name string) (int, error) {
data, changed, err := f.readChanged(name)
if err != nil || !changed {
@@ -357,14 +483,44 @@ func (f *Files) read(name string) (int, error) {
return f.takeIn(name, data, false)
}
// readChanged returns what the state file name holds, and whether that
// has changed since sww last read or wrote the file, as it has
// for a file sww never read or wrote. A missing file has not
// changed: it is written again at its next write.
func (f *Files) readChanged(name string) ([]byte, bool, error) {
path := filepath.Join(f.params.Dir, name)
// readCache takes in the cache file name at start, as read does. A cache
// file that cannot be read, or does not parse, does not stop the start:
// it is logged, and its cache starts empty and fills again. One that does
// not parse is set aside as an edit is, and one that cannot be read is
// written over at its next write.
func (f *Files) readCache(name string) int {
data, changed, err := f.readChanged(name)
if err != nil || !changed {
f.logUnread(name, err)
data, err := os.ReadFile(path) //nolint:gosec // a state file, in SWW_STATE_DIR
return 0
}
entries, err := f.takeIn(name, data, false)
if err != nil {
f.logUnread(name, f.setAside(name, err))
}
return entries
}
// logUnread logs err, the reason the cache file name was not read at
// start, unless it is nil.
func (f *Files) logUnread(name string, err error) {
if err != nil {
f.params.ProcessLog.Warn("cannot read a cache file, so its cache starts empty",
"file", filepath.Join(f.dir(name), name), "error", err.Error())
}
}
// readChanged returns what the file name holds, and whether that has
// changed since sww last read or wrote the file, as it has for a file
// sww never read or wrote. A missing file has not changed: it is written
// again at its next write.
func (f *Files) readChanged(name string) ([]byte, bool, error) {
path := filepath.Join(f.dir(name), name)
data, err := os.ReadFile(path) //nolint:gosec // a file in one of the two directories
if errors.Is(err, fs.ErrNotExist) {
return nil, false, nil
}
@@ -376,15 +532,39 @@ func (f *Files) readChanged(name string) ([]byte, bool, error) {
return data, sha256.Sum256(data) != f.sums[name], nil
}
// takeIn parses data, what the state file name holds, puts it into the
// takeIn parses data, what the file name holds, puts it into the
// part that keeps that state, in place of what the part held, and returns
// how many entries the file holds. edit is whether data is an admin's
// edit taken in while sww runs, rather than the file read at the
// start. An error names the file and, where the JSON decoder tells it,
// the line and column, or else the entry.
// the line and column, or else the entry. abuseipdb-checks-spent.json
// holds no entries.
func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) {
path := filepath.Join(f.params.Dir, name)
path := filepath.Join(f.dir(name), name)
var (
entries int
err error
)
if isCacheFile(name) {
entries, err = f.takeInCache(name, path, data)
} else {
entries, err = f.takeInState(name, path, data, edit)
}
if err != nil {
return 0, err
}
f.sums[name] = sha256.Sum256(data)
return entries, nil
}
// takeInState parses data, what the state file name, at path, holds, and
// puts it in, as takeIn describes.
func (f *Files) takeInState(name, path string, data []byte, edit bool) (int, error) {
var entries int
switch name {
@@ -418,6 +598,33 @@ func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) {
f.params.Limiter.Load(file.Clients, f.params.Now())
entries = len(file.Clients)
case abuseIPDBChecksSpentJSON:
var file abuseIPDBChecksSpentFile
err := parse(path, data, &file)
if err != nil {
return 0, err
}
f.params.AbuseIPDB.LoadChecksSpent(reputation.ChecksSpent{
Day: file.Day, Spent: file.Spent,
})
case alertsJSON:
waiting, err := f.takeInAlerts(path, data)
if err != nil {
return 0, err
}
entries = waiting
}
return entries, nil
}
// takeInCache parses data, what the cache file name, at path, holds, and
// puts it in, as takeIn describes.
func (f *Files) takeInCache(name, path string, data []byte) (int, error) {
switch name {
case lookupsJSON:
var file lookupsFile
@@ -427,9 +634,10 @@ func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) {
}
f.params.GeoJS.Load(file.Lookups)
entries = len(file.Lookups)
case reputationJSON:
var file reputationFile
return len(file.Lookups), nil
case listsJSON:
var file listsFile
err := parse(path, data, &file)
if err != nil {
@@ -441,21 +649,43 @@ func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) {
return 0, fmt.Errorf("%s: %w", path, err)
}
f.params.DNSBL.Load(file.Verdicts)
f.params.AbuseIPDB.Load(file.AbuseIPDB)
entries = len(file.Lists)
case alertsJSON:
waiting, err := f.takeInAlerts(path, data)
return len(file.Lists), nil
case dnsblJSON:
var file dnsblFile
err := parse(path, data, &file)
if err != nil {
return 0, err
}
entries = waiting
f.params.DNSBL.Load(file.Verdicts)
return len(file.Verdicts), nil
case routeWatchJSON:
var file routeWatchFile
err := parse(path, data, &file)
if err != nil {
return 0, err
}
if f.params.RouteWatch != nil {
f.params.RouteWatch.Load(file.Answers)
}
return len(file.Answers), nil
default: // abuseipdb-scores.json
var file abuseIPDBScoresFile
err := parse(path, data, &file)
if err != nil {
return 0, err
}
f.params.AbuseIPDB.Load(file.Scores)
return len(file.Scores), nil
}
f.sums[name] = sha256.Sum256(data)
return entries, nil
}
// takeInAlerts parses data, what alerts.json, at path, holds, puts it
@@ -493,17 +723,22 @@ func (f *Files) takeInAlerts(path string, data []byte) (int, error) {
return entries, nil
}
// writeFile writes the state file name from what sww holds. An
// edit made since sww last read or wrote the file is taken in
// first, so that it is not overwritten, or set aside if it does not
// parse. A file that cannot be read, or an edit that cannot be set
// aside, is left as it is, and the write given up. Every write is counted
// in the metrics, and one that fails or is given up as a failure.
// writeFile writes the file name from what sww holds. An edit made since
// sww last read or wrote the file is taken in first, so that it is not
// overwritten, or set aside if it does not parse. A state file that
// cannot be read, or an edit that cannot be set aside, is left as it is,
// and the write given up. A cache file that cannot be read is written
// over, since what it held is fetched again. Every write is counted in
// the metrics, and one that fails or is given up as a failure.
func (f *Files) writeFile(name string) error {
f.mu.Lock()
defer f.mu.Unlock()
data, changed, err := f.readChanged(name)
if err != nil && isCacheFile(name) {
err = nil
}
if err == nil && changed {
err = f.takeInEdit(name, data)
if err != nil {
@@ -519,7 +754,7 @@ func (f *Files) writeFile(name string) error {
}
if err == nil {
err = write(f.params.Dir, name, data)
err = write(f.dir(name), name, data)
}
if err == nil {
@@ -527,7 +762,7 @@ func (f *Files) writeFile(name string) error {
// fails, so that its next read does not take it for an admin's
// edit.
f.sums[name] = sha256.Sum256(data)
err = syncDirectory(f.params.Dir)
err = syncDirectory(f.dir(name))
}
f.params.Metrics.StateFileWritten(name, len(data), err)
@@ -535,20 +770,23 @@ func (f *Files) writeFile(name string) error {
return err
}
// setAside renames the state file name, an edit that does not parse with
// setAside renames the file name, an edit that does not parse with
// parseErr, to name.bad, for the admin to mend, and logs it with where in
// the file the error is, and raises a file_error alert for it. If the
// rename fails, the edit is left as it is, and the error returned is
// parseErr joined with the rename's.
func (f *Files) setAside(name string, parseErr error) error {
path := filepath.Join(f.params.Dir, name)
path := filepath.Join(f.dir(name), name)
err := os.Rename(path, path+".bad")
if err != nil {
return errors.Join(parseErr, err)
}
const setAside = "set aside an edit of a state file that does not parse"
setAside := "set aside an edit of a state file that does not parse"
if isCacheFile(name) {
setAside = "set aside an edit of a cache file that does not parse"
}
// Raised before it is logged, so that the alert is there once the log
// line is.
@@ -563,7 +801,7 @@ func (f *Files) setAside(name string, parseErr error) error {
return nil
}
// encode returns the state file name as sww writes it, from a
// encode returns the file name as sww writes it, from a
// snapshot of the part that keeps that state.
func (f *Files) encode(name string) ([]byte, error) {
switch name {
@@ -575,10 +813,24 @@ func (f *Files) encode(name string) ([]byte, error) {
return encodeOnePerLine("clients", f.params.Limiter.Snapshot())
case lookupsJSON:
return encodeOnePerLine("lookups", f.params.GeoJS.Snapshot())
case reputationJSON:
return encodeIndented(reputationFile{
Version: version, Lists: f.params.Lists.Snapshot(),
Verdicts: f.params.DNSBL.Snapshot(), AbuseIPDB: f.params.AbuseIPDB.Snapshot(),
case listsJSON:
return encodeIndented(listsFile{Version: version, Lists: f.params.Lists.Snapshot()})
case dnsblJSON:
return encodeOnePerLine("verdicts", f.params.DNSBL.Snapshot())
case abuseIPDBScoresJSON:
return encodeOnePerLine("scores", f.params.AbuseIPDB.Snapshot())
case routeWatchJSON:
var answers []routewatch.Answer
if f.params.RouteWatch != nil {
answers = f.params.RouteWatch.Snapshot()
}
return encodeOnePerLine("answers", answers)
case abuseIPDBChecksSpentJSON:
checks := f.params.AbuseIPDB.ChecksSpent()
return encodeIndented(abuseIPDBChecksSpentFile{
Version: version, Day: checks.Day, Spent: checks.Spent,
})
default: // alerts.json
held := f.params.Alerts.Snapshot()
@@ -748,14 +1000,8 @@ func (f *lookupsFile) check(data []byte) error {
// check refuses a list without its URL, which would name no list, or the
// time it was last tried, which would have it fetched at once, and a copy
// of it without the time it was fetched, or without its lines, which hold
// the list. It refuses a verdict without its zone or its client, which
// would be about no one, whether the zone lists the client, or the time
// it was fetched, which would drop it, and so an AbuseIPDB score without
// its client, the score, or the time it was fetched. A verdict's listed is
// false for a client the zone does not list, and a score can be 0, which
// the structs cannot tell from a missing one, so each is read again as
// written.
func (f *reputationFile) check(data []byte) error {
// the list.
func (f *listsFile) check([]byte) error {
for i, kept := range f.Lists {
switch {
case kept.URL == "":
@@ -769,6 +1015,15 @@ func (f *reputationFile) check(data []byte) error {
}
}
return nil
}
// check refuses a verdict without its zone or its client, which would be
// about no one, whether the zone lists the client, or the time it was
// fetched, which would drop it. A verdict's listed is false for a client
// the zone does not list, which Verdict cannot tell from a missing one,
// so each listed is read again as written.
func (f *dnsblFile) check(data []byte) error {
var written struct {
Verdicts []struct {
Listed *bool `json:"listed"`
@@ -783,28 +1038,27 @@ func (f *reputationFile) check(data []byte) error {
for i, verdict := range f.Verdicts {
switch {
case verdict.Zone == "":
return fmt.Errorf("verdicts %w", missing(i, "zone"))
return missing(i, "zone")
case !verdict.Client.IsValid():
return fmt.Errorf("verdicts %w", missing(i, "client"))
return missing(i, "client")
case written.Verdicts[i].Listed == nil:
return fmt.Errorf("verdicts %w", missing(i, "listed"))
return missing(i, "listed")
case verdict.Fetched.IsZero():
return fmt.Errorf("verdicts %w", missing(i, "fetched"))
return missing(i, "fetched")
}
}
return checkScores(f.AbuseIPDB.Scores, data)
return nil
}
// checkScores refuses an AbuseIPDB score, of scores, read from data, as
// reputationFile's check describes.
func checkScores(scores []reputation.Score, data []byte) error {
// check refuses an AbuseIPDB score without its client, the score, or the
// time it was fetched. A score can be 0, which Score cannot tell from a
// missing one, so each score is read again as written.
func (f *abuseIPDBScoresFile) check(data []byte) error {
var written struct {
AbuseIPDB struct {
Scores []struct {
Score *int64 `json:"score"`
} `json:"scores"`
} `json:"abuseipdb"`
Scores []struct {
Score *int64 `json:"score"`
} `json:"scores"`
}
err := json.Unmarshal(data, &written)
@@ -812,20 +1066,57 @@ func checkScores(scores []reputation.Score, data []byte) error {
return err
}
for i, kept := range scores {
for i, kept := range f.Scores {
switch {
case !kept.Client.IsValid():
return fmt.Errorf("abuseipdb scores %w", missing(i, "client"))
case written.AbuseIPDB.Scores[i].Score == nil:
return fmt.Errorf("abuseipdb scores %w", missing(i, "score"))
return missing(i, "client")
case written.Scores[i].Score == nil:
return missing(i, "score")
case kept.Fetched.IsZero():
return fmt.Errorf("abuseipdb scores %w", missing(i, "fetched"))
return missing(i, "fetched")
}
}
return nil
}
// check refuses an answer without its client, which would answer for
// nobody, its netblock, or the time routewatch gave it, which would drop
// it. "" is the netblock of an address without a live route, which Answer
// cannot tell from a missing one, so each netblock is read again as
// written.
func (f *routeWatchFile) check(data []byte) error {
var written struct {
Answers []struct {
Netblock *string `json:"netblock"`
} `json:"answers"`
}
err := json.Unmarshal(data, &written)
if err != nil {
return err
}
for i, answer := range f.Answers {
switch {
case !answer.Client.IsValid():
return missing(i, "client")
case written.Answers[i].Netblock == nil:
return missing(i, "netblock")
case answer.Answered.IsZero():
return missing(i, "answered")
}
}
return nil
}
// check refuses nothing, since a missing day or count means no check
// spent.
func (f *abuseIPDBChecksSpentFile) check([]byte) error {
return nil
}
// check refuses a cooldown without its event or when its alert was sent,
// which would hold back no repeat, alerts waiting for a destination with
// another name than webhook, slack or ntfy, most likely misspelt, an
@@ -947,22 +1238,37 @@ func (f *lookupsFile) toUTC() {
}
}
// toUTC converts every time of each list, verdict and AbuseIPDB score to
// UTC.
func (f *reputationFile) toUTC() {
// toUTC converts both times of each list to UTC.
func (f *listsFile) toUTC() {
for i := range f.Lists {
convertToUTC(&f.Lists[i].Tried, &f.Lists[i].Fetched)
}
}
// toUTC converts the time of each verdict to UTC.
func (f *dnsblFile) toUTC() {
for i := range f.Verdicts {
convertToUTC(&f.Verdicts[i].Fetched)
}
}
for i := range f.AbuseIPDB.Scores {
convertToUTC(&f.AbuseIPDB.Scores[i].Fetched)
// toUTC converts the time of each AbuseIPDB score to UTC.
func (f *abuseIPDBScoresFile) toUTC() {
for i := range f.Scores {
convertToUTC(&f.Scores[i].Fetched)
}
}
// toUTC converts the time of each answer to UTC.
func (f *routeWatchFile) toUTC() {
for i := range f.Answers {
convertToUTC(&f.Answers[i].Answered)
}
}
// toUTC does nothing: abuseipdb-checks-spent.json holds a day, not a time.
func (f *abuseIPDBChecksSpentFile) toUTC() {}
// toUTC converts every time of each cooldown, of the hour, of each alert
// waiting and of each anomaly counter to UTC.
func (f *alertsFile) toUTC() {
@@ -1114,7 +1420,8 @@ func write(dir, name string, data []byte) error {
// syncDirectory syncs dir to the disk, so that a rename in it lasts.
func syncDirectory(dir string) error {
directory, err := os.Open(dir) //nolint:gosec // SWW_STATE_DIR itself
//nolint:gosec // SWW_WAF_STATE_DIR or SWW_WAF_CACHE_DIR itself
directory, err := os.Open(dir)
if err != nil {
return err
}
@@ -1124,7 +1431,7 @@ func syncDirectory(dir string) error {
// writeSynced writes data to the file at path, and syncs it to the disk.
func writeSynced(path string, data []byte) error {
//nolint:gosec // a state file's temporary file, in SWW_STATE_DIR
//nolint:gosec // a file's temporary file, in SWW_WAF_STATE_DIR or SWW_WAF_CACHE_DIR
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
if err != nil {
return err
File diff suppressed because it is too large. Load diff
+13 -15
View File
@@ -10,6 +10,8 @@ import (
"net/url"
"time"
"github.com/spf13/viper"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
@@ -21,17 +23,17 @@ var errHealthEndpoint = errors.New("the health endpoint answered")
// HealthCheck is the container's health check. In waf's container, where
// SWW_COMMAND is waf, it returns 0 while waf answers its health
// endpoint on 127.0.0.1, at the port in SWW_LISTEN_ADDR. In rproxy's container,
// endpoint on 127.0.0.1, at the port in SWW_WAF_LISTEN_ADDR. In rproxy's container,
// where SWW_COMMAND is rproxy, unset or empty, it returns 0 while rproxy
// answers its health endpoint on 127.0.0.1, at the port in SWRP_LISTEN_ADDR, and
// the app accepts connections at the address in SWRP_UPSTREAM_URL. Otherwise,
// answers its health endpoint on 127.0.0.1, at the port in SWW_RPROXY_LISTEN_ADDR, and
// the app accepts connections at the address in SWW_RPROXY_UPSTREAM_URL. Otherwise,
// any other value of SWW_COMMAND included, it writes why to stderr and
// returns 1. It reads no other setting, nor a file that another names, so neither
// can fail it.
// args are the arguments after `healthcheck`; it takes none, and given
// one it names it on stderr and returns 1 without checking anything.
func HealthCheck(
ctx context.Context, args []string, lookupEnv func(string) (string, bool),
ctx context.Context, args []string, v *viper.Viper,
stderr io.Writer,
) int {
if len(args) > 0 {
@@ -44,15 +46,15 @@ func HealthCheck(
ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
defer cancel()
command, err := config.Command(lookupEnv)
command, err := config.Command(v)
switch {
case err != nil:
err = fmt.Errorf("invalid setting: %w", err)
case command == "waf":
err = checkWAF(ctx, lookupEnv)
err = checkWAF(ctx, v)
default:
err = checkRProxyAndApp(ctx, lookupEnv)
err = checkRProxyAndApp(ctx, v)
}
if err != nil {
@@ -65,10 +67,8 @@ func HealthCheck(
}
// checkWAF asks waf's health endpoint.
func checkWAF(
ctx context.Context, lookupEnv func(string) (string, bool),
) error {
listenAddr, err := config.WAFListenAddr(lookupEnv)
func checkWAF(ctx context.Context, v *viper.Viper) error {
listenAddr, err := config.WAFListenAddr(v)
if err != nil {
return fmt.Errorf("invalid setting: %w", err)
}
@@ -82,10 +82,8 @@ func checkWAF(
}
// checkRProxyAndApp asks rproxy's health endpoint, and connects to the app.
func checkRProxyAndApp(
ctx context.Context, lookupEnv func(string) (string, bool),
) error {
listenAddr, upstreamURL, err := config.RProxyListenAddrAndUpstreamURL(lookupEnv)
func checkRProxyAndApp(ctx context.Context, v *viper.Viper) error {
listenAddr, upstreamURL, err := config.RProxyListenAddrAndUpstreamURL(v)
if err != nil {
return fmt.Errorf("invalid setting: %w", err)
}
+14 -20
View File
@@ -12,6 +12,7 @@ import (
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
"sneak.berlin/go/smallwebwaf/internal/sww"
)
@@ -41,7 +42,7 @@ func TestHealthCheckInWAFsContainer(t *testing.T) {
exited <- run(ctx, settings, out)
}()
addr, _ := out.line(t, "msg", "starting")["address"].(string)
addr, _ := out.lineBeforeExit(t, "starting", exited)["address"].(string)
_, port, _ := net.SplitHostPort(addr)
// The container's settings: an address to listen on with an empty
@@ -54,9 +55,9 @@ func TestHealthCheckInWAFsContainer(t *testing.T) {
// or invalid token file, or an invalid value of another setting, does not
// fail it.
for _, other := range []struct{ name, value string }{
{"SWW_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")},
{"SWW_METRICS_TOKEN_FILE", writeFile(t, "too short\n")},
{"SWW_MODE", "neither"},
{"SWW_WAF_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")},
{"SWW_WAF_METRICS_TOKEN_FILE", writeFile(t, "too short\n")},
{"SWW_WAF_MODE", "neither"},
} {
wantHealthCheck(t, map[string]string{
commandSetting: wafCommand,
@@ -72,7 +73,7 @@ func TestHealthCheckInWAFsContainer(t *testing.T) {
}, 1, "unhealthy: ask waf: ")
wantHealthCheck(t, map[string]string{
commandSetting: wafCommand, listenAddr: "8080",
}, 1, "unhealthy: invalid setting: SWW_LISTEN_ADDR: ")
}, 1, "unhealthy: invalid setting: SWW_WAF_LISTEN_ADDR: ")
stop()
@@ -111,8 +112,8 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
0, "")
// Another setting does not fail it, even an invalid one, and neither does
// SWRP_WAF_BACKEND being unset.
wantHealthCheck(t, settings("SWRP_TIMEOUT_MS", "soon"), 0, "")
// SWW_RPROXY_WAF_BACKEND being unset.
wantHealthCheck(t, settings("SWW_RPROXY_TIMEOUT_MS", "soon"), 0, "")
// Another test can take a closed server's port at once, so the app's
// address here is a multicast address, to which TCP cannot connect.
@@ -123,13 +124,14 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
// itself.
wantHealthCheck(t, settings(rproxyListenAddr, ":0"), 1, "unhealthy: ask rproxy: ")
wantHealthCheck(t, settings(rproxyListenAddr, "8080"), 1,
"unhealthy: invalid setting: SWRP_LISTEN_ADDR: ")
"unhealthy: invalid setting: SWW_RPROXY_LISTEN_ADDR: ")
// No setting of rproxy's has a form given as a file.
// A setting of rproxy's can be given as a file too, here the listen
// address, which the health check reads.
wantHealthCheck(t, map[string]string{
rproxyListenAddr + "_FILE": writeFile(t, ":"+port+"\n"),
rproxyUpstreamURL: app.URL,
}, 1, "unhealthy: ask rproxy: ")
}, 0, "")
}
func TestHealthCheckFailsWithAnyOtherCommand(t *testing.T) {
@@ -145,11 +147,7 @@ func TestHealthCheckRefusesAnArgument(t *testing.T) {
var stderr bytes.Buffer
noSettings := func(string) (string, bool) {
return "", false
}
got := sww.HealthCheck(t.Context(), []string{"now"}, noSettings, &stderr)
got := sww.HealthCheck(t.Context(), []string{"now"}, configtest.Viper(nil), &stderr)
want := "sww healthcheck: unexpected argument \"now\"\n"
if got != 1 || stderr.String() != want {
@@ -166,11 +164,7 @@ func wantHealthCheck(t *testing.T, env map[string]string, status int, message st
var stderr bytes.Buffer
got := sww.HealthCheck(t.Context(), nil, func(name string) (string, bool) {
value, ok := env[name]
return value, ok
}, &stderr)
got := sww.HealthCheck(t.Context(), nil, configtest.Viper(env), &stderr)
wrote := stderr.String()
if got != status || !strings.HasPrefix(wrote, message) ||
+3 -3
View File
@@ -15,7 +15,7 @@ import (
// RunRProxy is `sww rproxy`: it reads rproxy's settings, then serves
// requests until ctx is done. Then it gives the requests in progress
// shutdownTimeout to finish, and the reports being sent SWRP_TIMEOUT_MS. It
// shutdownTimeout to finish, and the reports being sent SWW_RPROXY_TIMEOUT_MS. It
// returns the process's exit status, 1 when rproxy cannot start. Its process
// messages name the host's name as instance, and it writes those at info or
// more severe: rproxy has no setting for either.
@@ -23,7 +23,7 @@ func RunRProxy(ctx context.Context, params Params) int {
hostname, _ := os.Hostname() // "" when the host has no name to give
processLog := requestlog.NewProcessLogger(params.Stdout, hostname, slog.LevelInfo)
cfg, err := config.RProxyFromEnvironment(params.LookupEnv)
cfg, err := config.RProxyFromEnvironment(params.Viper)
if err != nil {
processLog.Error("invalid setting", "error", err.Error())
@@ -38,7 +38,7 @@ func RunRProxy(ctx context.Context, params Params) int {
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr)
if err != nil {
processLog.Error("cannot listen on SWRP_LISTEN_ADDR", "error", err.Error())
processLog.Error("cannot listen on SWW_RPROXY_LISTEN_ADDR", "error", err.Error())
return 1
}
+50 -57
View File
@@ -19,19 +19,20 @@ import (
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
"sneak.berlin/go/smallwebwaf/internal/sww"
"sneak.berlin/go/smallwebwaf/internal/waf"
)
const (
// wafBackend is the setting that names waf to rproxy.
wafBackend = "SWRP_WAF_BACKEND"
wafBackend = "SWW_RPROXY_WAF_BACKEND"
// rproxyListenAddr is the setting that says where rproxy listens.
rproxyListenAddr = "SWRP_LISTEN_ADDR"
rproxyListenAddr = "SWW_RPROXY_LISTEN_ADDR"
// rproxyUpstreamURL is the setting that says where the app is.
rproxyUpstreamURL = "SWRP_UPSTREAM_URL"
rproxyUpstreamURL = "SWW_RPROXY_UPSTREAM_URL"
// rproxyTimeout is the setting that says how long rproxy waits for waf.
rproxyTimeout = "SWRP_TIMEOUT_MS"
rproxyTimeout = "SWW_RPROXY_TIMEOUT_MS"
// bannedProbe is a path the default rule file bans a client for.
bannedProbe = "/.env"
// otherClient is a client the tests do not ban.
@@ -43,12 +44,8 @@ const (
func runRProxy(ctx context.Context, env map[string]string, out *output) int {
return sww.RunRProxy(ctx, sww.Params{
Version: testVersion,
LookupEnv: func(name string) (string, bool) {
value, ok := env[name]
return value, ok
},
Stdout: out,
Viper: configtest.Viper(env),
Stdout: out,
})
}
@@ -64,13 +61,15 @@ func startRProxy(t *testing.T, backend, appURL string) (string, *output) {
go func() {
exited <- runRProxy(ctx, map[string]string{
rproxyListenAddr: localhost + ":0",
"SWRP_UPSTREAM_URL": appURL,
"SWRP_TRUSTED_PROXIES": localhost + "/32",
wafBackend: backend,
rproxyListenAddr: localhost + ":0",
"SWW_RPROXY_UPSTREAM_URL": appURL,
"SWW_RPROXY_TRUSTED_PROXIES": localhost + "/32",
wafBackend: backend,
}, out)
}()
addr, _ := out.lineBeforeExit(t, "starting", exited)["address"].(string)
t.Cleanup(func() {
stop()
@@ -86,8 +85,6 @@ func startRProxy(t *testing.T, backend, appURL string) (string, *output) {
out.line(t, "msg", "stopped")
})
addr, _ := out.line(t, "msg", "starting")["address"].(string)
return "http://" + addr, out
}
@@ -105,7 +102,7 @@ func startWAF(t *testing.T, env map[string]string) (string, *output, func()) {
exited <- run(ctx, env, out)
}()
addr, _ := out.line(t, "msg", "starting")["address"].(string)
addr, _ := out.lineBeforeExit(t, "starting", exited)["address"].(string)
return addr, out, func() {
stop()
@@ -507,9 +504,10 @@ func TestAStatusWAFDoesNotTakeIsReportedAs599(t *testing.T) {
}
// startRProxyProcess runs `sww rproxy` in a process of its own, with the
// settings in env, each as NAME=value. It returns where rproxy listens, the
// process, and what rproxy writes.
func startRProxyProcess(t *testing.T, env ...string) (string, *exec.Cmd, *output) {
// settings in env, each as NAME=value. It returns where rproxy listens and a
// function that sends rproxy SIGTERM and fails the test unless it exits with
// status 0 within waitLimit.
func startRProxyProcess(t *testing.T, env ...string) (string, func()) {
t.Helper()
binary, err := os.Executable()
@@ -517,31 +515,34 @@ func startRProxyProcess(t *testing.T, env ...string) (string, *exec.Cmd, *output
t.Fatalf("find the test binary: %v", err)
}
ctx, cancel := context.WithTimeout(t.Context(), 3*waitLimit)
t.Cleanup(cancel)
out := &output{}
cmd := exec.CommandContext(ctx, binary) //nolint:gosec // this test binary
cmd := exec.CommandContext(t.Context(), binary) //nolint:gosec // this test binary
cmd.Stdout = out
cmd.Env = append(os.Environ(),
commandForMain+"=rproxy",
"SWRP_LISTEN_ADDR="+localhost+":0")
"SWW_RPROXY_LISTEN_ADDR="+localhost+":0")
cmd.Env = append(cmd.Env, env...)
err = cmd.Start()
if err != nil {
t.Fatalf("start: %v", err)
exited := startProcess(t, cmd)
addr, _ := out.lineBeforeExit(t, "starting", exited)["address"].(string)
return addr, func() {
err := cmd.Process.Signal(syscall.SIGTERM)
if err != nil {
t.Fatalf("signal: %v", err)
}
select {
case status := <-exited:
if status != 0 {
t.Errorf("exit status %d, want 0; output:\n%s", status, out.text())
}
case <-time.After(waitLimit):
t.Error("still running after being told to stop")
}
}
t.Cleanup(func() {
_ = cmd.Process.Kill()
_ = cmd.Wait()
})
addr, _ := out.line(t, "msg", "starting")["address"].(string)
return addr, cmd, out
}
func TestRProxyAsksAWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) {
@@ -572,9 +573,9 @@ func TestRProxyAsksAWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) {
t.Fatalf("write the certificate: %v", err)
}
addr, _, _ := startRProxyProcess(t,
addr, _ := startRProxyProcess(t,
wafBackend+"="+backend.URL,
"SWRP_UPSTREAM_URL="+startApp(t),
"SWW_RPROXY_UPSTREAM_URL="+startApp(t),
"SSL_CERT_FILE="+certificates)
for name, headers := range map[string]string{
@@ -639,10 +640,10 @@ func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) {
defer stop()
env := map[string]string{
rproxyListenAddr: localhost + ":0",
"SWRP_UPSTREAM_URL": startApp(t),
rproxyTimeout: "60000",
wafBackend: backend.URL,
rproxyListenAddr: localhost + ":0",
"SWW_RPROXY_UPSTREAM_URL": startApp(t),
rproxyTimeout: "60000",
wafBackend: backend.URL,
}
out := &output{}
exited := make(chan int, 1)
@@ -651,7 +652,7 @@ func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) {
exited <- runRProxy(ctx, env, out)
}()
addr, _ := out.line(t, "msg", "starting")["address"].(string)
addr, _ := out.lineBeforeExit(t, "starting", exited)["address"].(string)
wantStatus(t, "http://"+addr+"/", placed, http.StatusOK)
@@ -716,7 +717,7 @@ func TestRProxyStoppingCutsOffARequestAfterFiveSeconds(t *testing.T) {
const (
cutOffAfter = 5 * time.Second
upstreamSetting = "SWRP_UPSTREAM_URL"
upstreamSetting = "SWW_RPROXY_UPSTREAM_URL"
)
asked := make(chan struct{})
@@ -750,7 +751,7 @@ func TestRProxyStoppingCutsOffARequestAfterFiveSeconds(t *testing.T) {
exited <- runRProxy(ctx, env, out)
}()
addr, _ := out.line(t, "msg", "starting")["address"].(string)
addr, _ := out.lineBeforeExit(t, "starting", exited)["address"].(string)
conn := sendAndKeepOpen(t, addr, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
select {
@@ -838,14 +839,14 @@ func TestRProxyAddressInUseStopsTheStart(t *testing.T) {
t.Errorf("exit status %d, want 1", status)
}
out.line(t, "msg", "cannot listen on SWRP_LISTEN_ADDR")
out.line(t, "msg", "cannot listen on SWW_RPROXY_LISTEN_ADDR")
}
func TestMainRunsRProxy(t *testing.T) {
t.Parallel()
// Nothing listens there; the health check does not ask.
addr, cmd, out := startRProxyProcess(t, wafBackend+"=http://127.0.0.1:9")
addr, stop := startRProxyProcess(t, wafBackend+"=http://127.0.0.1:9")
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
"http://"+addr+waf.HealthPath, http.NoBody)
@@ -868,13 +869,5 @@ func TestMainRunsRProxy(t *testing.T) {
t.Errorf("health check answered %q (%v)", body, err)
}
err = cmd.Process.Signal(syscall.SIGTERM)
if err != nil {
t.Fatalf("signal: %v", err)
}
err = cmd.Wait()
if err != nil {
t.Errorf("exit: %v; output:\n%s", err, out.text())
}
stop()
}
+31 -26
View File
@@ -1,9 +1,9 @@
// Package sww runs the sww process, as the command it is given.
// `sww waf` reads the settings, the rule files, the lookup database
// and the state files, answers rproxy's questions until it is told to stop, and
// then stops in an orderly way, writing the state files. `sww rproxy`
// reads rproxy's settings alone, and runs the reverse proxy in front of one app
// that asks waf about each request. `sww healthcheck` is the
// and the state and cache files, answers rproxy's questions until it is told to
// stop, and then stops in an orderly way, writing the state and cache files.
// `sww rproxy` reads rproxy's settings alone, and runs the reverse proxy in
// front of one app that asks waf about each request. `sww healthcheck` is the
// container's health check.
package sww
@@ -20,6 +20,8 @@ import (
"syscall"
"time"
"github.com/spf13/viper"
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
@@ -38,7 +40,7 @@ import (
const shutdownTimeout = 5 * time.Second
// remoteLogStopTimeout is how long, as waf stops, the log lines still
// waiting are sent to SWW_LOG_REMOTE_URL before they are given up. stdout has
// waiting are sent to SWW_WAF_LOG_REMOTE_URL before they are given up. stdout has
// carried them.
const remoteLogStopTimeout = 2 * time.Second
@@ -59,8 +61,9 @@ commands:
type Params struct {
// Version is the version of the binary, set when it is built.
Version string
// LookupEnv reads an environment variable, normally os.LookupEnv.
LookupEnv func(string) (string, bool)
// Viper reads the settings, normally the viper config.NewViper returns
// for the process's environment.
Viper *viper.Viper
// Stdout receives the request log and the process's own messages.
Stdout io.Writer
}
@@ -79,7 +82,7 @@ func Main(version string) int {
syscall.SIGTERM, os.Interrupt)
defer stop()
params := Params{Version: version, LookupEnv: os.LookupEnv, Stdout: os.Stdout}
params := Params{Version: version, Viper: config.NewViper(), Stdout: os.Stdout}
switch args[0] {
case "rproxy":
@@ -87,7 +90,7 @@ func Main(version string) int {
case "waf":
return RunWAF(ctx, params)
case "healthcheck":
return HealthCheck(ctx, args[1:], os.LookupEnv, os.Stderr)
return HealthCheck(ctx, args[1:], params.Viper, os.Stderr)
default:
return printUsage(os.Stderr)
}
@@ -101,22 +104,22 @@ func printUsage(w io.Writer) int {
}
// RunWAF is `sww waf`: it reads the settings, the rule files,
// the lookup database and the state files, then serves requests until ctx is
// done. It returns the process's exit status, 1 when waf cannot start.
// the lookup database and the state and cache files, then serves requests until
// ctx is done. It returns the process's exit status, 1 when waf cannot start.
func RunWAF(ctx context.Context, params Params) int {
// Until the settings are read, the one message is an invalid setting's
// error, which every SWW_LOG_LEVEL lets through.
// error, which every SWW_WAF_LOG_LEVEL lets through.
processLog := requestlog.NewProcessLogger(params.Stdout,
config.InstanceName(params.LookupEnv), slog.LevelError)
config.InstanceName(params.Viper), slog.LevelError)
cfg, err := config.FromEnvironment(params.LookupEnv)
cfg, err := config.FromEnvironment(params.Viper)
if err != nil {
processLog.Error("invalid setting", "error", err.Error())
return 1
}
// While SWW_LOG_REMOTE_URL is set, every line on stdout from here on
// While SWW_WAF_LOG_REMOTE_URL is set, every line on stdout from here on
// is sent there too.
stdout := params.Stdout
@@ -171,14 +174,14 @@ func RunWAF(ctx context.Context, params Params) int {
files, err := loadStateFiles(cfg, server, alertQueue, now, processLog)
if err != nil {
processLog.Error("cannot use the state files", "error", err.Error())
processLog.Error("cannot use the state and cache files", "error", err.Error())
return 1
}
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr)
if err != nil {
processLog.Error("cannot listen on SWW_LISTEN_ADDR", "error", err.Error())
processLog.Error("cannot listen on SWW_WAF_LISTEN_ADDR", "error", err.Error())
return 1
}
@@ -192,7 +195,7 @@ func RunWAF(ctx context.Context, params Params) int {
}
// newServer returns the server waf runs, with the metrics of the alerts,
// after reading the lookup database while SWW_LOOKUP_SOURCE is file. A lookup
// after reading the lookup database while SWW_WAF_LOOKUP_SOURCE is file. A lookup
// database that cannot be read is an error.
func newServer(
cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
@@ -236,14 +239,15 @@ func newServer(
return server, nil
}
// loadStateFiles reads the state files into the parts of server and into
// alertQueue, as state.Load does.
// loadStateFiles reads the state and cache files into the parts of server and
// into alertQueue, as state.Load does.
func loadStateFiles(
cfg *config.Config, server *waf.Server, alertQueue *alerts.Queue,
now func() time.Time, processLog *slog.Logger,
) (*state.Files, error) {
return state.Load(state.Params{
Dir: cfg.StateDir,
CacheDir: cfg.CacheDir,
WriteDelay: cfg.StateWriteDelay,
CounterInterval: cfg.StateCounterInterval,
Ledger: server.Ledger,
@@ -252,6 +256,7 @@ func loadStateFiles(
Lists: server.Lists,
DNSBL: server.DNSBL,
AbuseIPDB: server.AbuseIPDB,
RouteWatch: server.RouteWatch,
Alerts: alertQueue,
Anomalies: server.Anomalies,
Now: now,
@@ -281,7 +286,7 @@ func newAlertQueue(
}
// newRemoteLogSender returns a sender of the log lines to
// SWW_LOG_REMOTE_URL, with the settings for it.
// SWW_WAF_LOG_REMOTE_URL, with the settings for it.
func newRemoteLogSender(cfg *config.Config) *remotelog.Sender {
return remotelog.New(remotelog.Params{
URL: cfg.LogRemoteURL,
@@ -317,13 +322,13 @@ func startSending(
}
}
// serve serves requests on listener, writes the state files as they are
// due, takes in an admin's edits of them, reads the rule files again as
// serve serves requests on listener, writes the state and cache files as they
// are due, takes in an admin's edits of them, reads the rule files again as
// they change, and the lookup database when it is replaced, fetches the
// lists the settings name by URL as they are due, and sends the alerts,
// until ctx is done. Then it gives the requests in progress
// shutdownTimeout to finish, and writes every state file, alerts.json with
// the alerts still waiting.
// shutdownTimeout to finish, and writes every state and cache file,
// alerts.json with the alerts still waiting.
func serve(
ctx context.Context, server *waf.Server, listener net.Listener,
files *state.Files, ruleFiles *rules.Files, alertQueue *alerts.Queue,
@@ -393,7 +398,7 @@ func serve(
err = files.WriteAll()
if err != nil {
processLog.Error("writing the state files failed", "error", err.Error())
processLog.Error("writing the state and cache files failed", "error", err.Error())
return 1
}
+163 -128
View File
@@ -20,6 +20,7 @@ import (
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/config/configtest"
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
"sneak.berlin/go/smallwebwaf/internal/sww"
"sneak.berlin/go/smallwebwaf/internal/waf"
@@ -34,21 +35,22 @@ const (
testVersion = "test"
// localhost is where the tests listen.
localhost = "127.0.0.1"
listenAddr = "SWW_LISTEN_ADDR"
stateDir = "SWW_STATE_DIR"
stateWriteDelay = "SWW_STATE_WRITE_DELAY"
stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL"
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
rulesDir = "SWW_RULES_DIR"
lookupSource = "SWW_LOOKUP_SOURCE"
lookupDBPath = "SWW_LOOKUP_DB_PATH"
instanceName = "SWW_INSTANCE_NAME"
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
// adminSecret is the SWW_ADMIN_TOKEN the tests set.
listenAddr = "SWW_WAF_LISTEN_ADDR"
stateDir = "SWW_WAF_STATE_DIR"
stateWriteDelay = "SWW_WAF_STATE_WRITE_DELAY"
stateCounterInterval = "SWW_WAF_STATE_COUNTER_INTERVAL"
cacheDir = "SWW_WAF_CACHE_DIR"
rateLimitPerDay = "SWW_WAF_RATE_LIMIT_PER_DAY"
rateLimitExemptNets = "SWW_WAF_RATE_LIMIT_EXEMPT_NETS"
rulesDir = "SWW_WAF_RULES_DIR"
lookupSource = "SWW_WAF_LOOKUP_SOURCE"
lookupDBPath = "SWW_WAF_LOOKUP_DB_PATH"
instanceName = "SWW_WAF_INSTANCE_NAME"
adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
metricsToken = "SWW_WAF_METRICS_TOKEN" //nolint:gosec // the setting's name
// adminSecret is the SWW_WAF_ADMIN_TOKEN the tests set.
adminSecret = "fedcba9876543210fedcba9876543210"
// instance is the SWW_INSTANCE_NAME the tests set where they look at
// instance is the SWW_WAF_INSTANCE_NAME the tests set where they look at
// it.
instance = "fsn1app1/blog"
// greeting is what the tests' app answers.
@@ -149,20 +151,24 @@ func (o *output) text() string {
}
// run runs `sww waf` with the settings in env until ctx is done,
// and returns its exit status. SWW_LOOKUP_SOURCE is off unless env sets it,
// and returns its exit status. SWW_WAF_LOOKUP_SOURCE is off unless env sets it,
// so that no test sends GeoJS its clients' addresses.
func run(ctx context.Context, env map[string]string, out *output) int {
v := configtest.Viper(env)
if _, set := env["SWW_WAF_LOOKUP_SOURCE"]; !set {
v.Set("SWW_WAF_LOOKUP_SOURCE", "off")
}
// The cache files go beside the state files unless the test says
// otherwise.
if _, set := env[cacheDir]; !set && env[stateDir] != "" {
v.Set(cacheDir, env[stateDir])
}
return sww.RunWAF(ctx, sww.Params{
Version: testVersion,
LookupEnv: func(name string) (string, bool) {
value, ok := env[name]
if !ok && name == "SWW_LOOKUP_SOURCE" {
return "off", true
}
return value, ok
},
Stdout: out,
Viper: v,
Stdout: out,
})
}
@@ -172,8 +178,8 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
out := &output{}
status := run(t.Context(), map[string]string{
"SWW_BYTES_LIMIT_PER_MINUTE": "lots",
instanceName: instance,
"SWW_WAF_BYTES_LIMIT_PER_MINUTE": "lots",
instanceName: instance,
}, out)
if status != 1 {
t.Errorf("exit status %d, want 1", status)
@@ -184,7 +190,7 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
if line["type"] != "process" || line["instance"] != instance ||
line["level"] != "ERROR" ||
!strings.HasPrefix(message, "SWW_BYTES_LIMIT_PER_MINUTE: ") {
!strings.HasPrefix(message, "SWW_WAF_BYTES_LIMIT_PER_MINUTE: ") {
t.Errorf("start refused with %v", line)
}
}
@@ -240,7 +246,7 @@ func TestAddressInUseStopsTheStart(t *testing.T) {
t.Errorf("exit status %d, want 1", status)
}
out.line(t, "msg", "cannot listen on SWW_LISTEN_ADDR")
out.line(t, "msg", "cannot listen on SWW_WAF_LISTEN_ADDR")
}
func TestServesUntilToldToStop(t *testing.T) {
@@ -304,11 +310,11 @@ func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) {
go func() {
exited <- run(ctx, map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
"SWW_BLOCKLIST_URLS": lists.URL + "/tor.txt",
"SWW_LOG_LEVEL": "warn",
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
"SWW_WAF_BLOCKLIST_URLS": lists.URL + "/tor.txt",
"SWW_WAF_LOG_LEVEL": "warn",
}, out)
}()
@@ -411,7 +417,7 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
t.Parallel()
const scope = "SWW_BAN_SCOPE_V4_PREFIX"
const scope = "SWW_WAF_BAN_SCOPE_V4_PREFIX"
env := map[string]string{
listenAddr: localhost + ":0",
@@ -531,7 +537,7 @@ func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
rulesDir: dir,
// The requests sent until the rule takes effect must not break a
// rate limit, whose ban would refuse them too.
"SWW_RATE_LIMIT_PER_MINUTE": "off",
"SWW_WAF_RATE_LIMIT_PER_MINUTE": "off",
}
out := runUntilStopped(t, env, func(url string) {
@@ -574,7 +580,7 @@ func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) {
dir := filepath.Join(t.TempDir(), "rules.d")
wantRulesRefused(t, dir, "SWW_RULES_DIR cannot be read: open "+dir+
wantRulesRefused(t, dir, "SWW_WAF_RULES_DIR cannot be read: open "+dir+
": no such file or directory")
}
@@ -586,14 +592,14 @@ func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(t *testing.T) {
status := run(t.Context(), map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE": path,
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
}, out)
if status != 1 {
t.Fatalf("exit status %d, want 1", status)
}
line := out.line(t, "msg", "cannot use the Core Rule Set")
want := "SWW_CORE_RULE_SET_EXCLUSIONS_FILE: read the exclusions file: open " +
want := "SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE: read the exclusions file: open " +
path + ": no such file or directory"
if line["error"] != want {
@@ -618,7 +624,8 @@ func TestCoreRuleSetExclusionsFileThatCannotBeUsedStopsTheStartWhileTheCoreRuleS
status := run(t.Context(), map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
"SWW_WAF_MODE": config.WAFModeOff, "SWW_CORE_RULE_SET_EXCLUSIONS_FILE": path,
"SWW_WAF_CORE_RULE_SET_MODE": config.WAFModeOff,
"SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE": path,
}, out)
if status != 1 {
t.Fatalf("%s: exit status %d, want 1", path, status)
@@ -637,14 +644,14 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
writeLookupDatabase(t, path, "DE")
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
metricsToken: token,
instanceName: instance,
lookupSource: "file",
lookupDBPath: path,
"SWW_TAG_DENY": "country=KP",
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
metricsToken: token,
instanceName: instance,
lookupSource: "file",
lookupDBPath: path,
"SWW_WAF_TAG_DENY": "country=KP",
// The requests sent until a replacement takes effect, and those
// for the metrics, must not break a rate limit, whose ban would
// refuse them too.
@@ -699,7 +706,7 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
}
}
func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T) {
func TestBlocklistTriesAndCopiesKeptInListsJSONAcrossRestarts(t *testing.T) {
t.Parallel()
const (
@@ -732,14 +739,15 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T)
t.Cleanup(server.Close)
torURL, dropURL := server.URL+torPath, server.URL+dropPath
dir := t.TempDir()
cache := t.TempDir()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: dir,
rulesDir: t.TempDir(),
metricsToken: token,
instanceName: instance,
"SWW_BLOCKLIST_URLS": torURL,
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
cacheDir: cache,
rulesDir: t.TempDir(),
metricsToken: token,
instanceName: instance,
"SWW_WAF_BLOCKLIST_URLS": torURL,
// The requests sent until the list takes effect, and those for the
// metrics, must not break a rate limit, whose ban would refuse them
// too.
@@ -755,12 +763,12 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T)
})
// Restarted with drop.txt named after it and the server answering, tor.txt
// waits SWW_BLOCKLIST_REFRESH after its failed try, kept in reputation.json,
// waits SWW_WAF_BLOCKLIST_REFRESH after its failed try, kept in lists.json,
// while drop.txt, never tried, is fetched at once. Lists are fetched in the
// order named, so once drop.txt refuses the client, tor.txt has had its turn.
failing.Store(false)
env["SWW_BLOCKLIST_URLS"] = torURL + "," + dropURL
env["SWW_WAF_BLOCKLIST_URLS"] = torURL + "," + dropURL
out := runUntilStopped(t, env, func(url string) {
for statusFrom(t, url, placed) != http.StatusForbidden {
time.Sleep(pollInterval)
@@ -773,8 +781,8 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T)
}
// After another restart, with the server failing, the copy of drop.txt
// kept in reputation.json, its copyright line included, refuses the
// client from the first request.
// kept in lists.json, its copyright line included, refuses the client
// from the first request.
failing.Store(true)
out = runUntilStopped(t, env, func(url string) {
@@ -782,16 +790,16 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T)
})
wantDeniedByList(t, out.line(t, "type", "request"), dropURL)
path := filepath.Join(dir, "reputation.json")
path := filepath.Join(cache, "lists.json")
kept, err := os.ReadFile(path) //nolint:gosec // a file in the test's directory
if err != nil || !strings.Contains(string(kept), `"`+copyright+`"`) {
t.Errorf("reputation.json holds\n%s\nwant the copy with %q (%v)", kept, copyright,
t.Errorf("lists.json holds\n%s\nwant the copy with %q (%v)", kept, copyright,
err)
}
}
func TestCrowdSecDecisionListKeptInReputationJSONAcrossARestart(t *testing.T) {
func TestCrowdSecDecisionListKeptInListsJSONAcrossARestart(t *testing.T) {
t.Parallel()
const bouncerKey = "crowdsec-key-0123456789abcdef"
@@ -814,13 +822,14 @@ func TestCrowdSecDecisionListKeptInReputationJSONAcrossARestart(t *testing.T) {
}))
t.Cleanup(engine.Close)
dir := t.TempDir()
dir, cache := t.TempDir(), t.TempDir()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: dir,
rulesDir: t.TempDir(),
"SWW_CROWDSEC_LAPI_URL": engine.URL,
"SWW_CROWDSEC_LAPI_KEY": bouncerKey,
listenAddr: localhost + ":0",
stateDir: dir,
cacheDir: cache,
rulesDir: t.TempDir(),
"SWW_WAF_CROWDSEC_LAPI_URL": engine.URL,
"SWW_WAF_CROWDSEC_LAPI_KEY": bouncerKey,
}
// Once the list is fetched, the client's request bans it.
@@ -836,7 +845,7 @@ func TestCrowdSecDecisionListKeptInReputationJSONAcrossARestart(t *testing.T) {
t.Errorf("bans.json holds %v, want the ban for crowdsec on %s", ban, placed)
}
// Restarted with the engine down, the copy kept in reputation.json bans
// Restarted with the engine down, the copy kept in lists.json bans
// another client in the netblock from its first request.
down.Store(true)
@@ -844,22 +853,25 @@ func TestCrowdSecDecisionListKeptInReputationJSONAcrossARestart(t *testing.T) {
wantStatus(t, url, "203.0.113.10", http.StatusForbidden)
})
// The key is in neither run's output, nor in a state file.
files := []string{"bans.json", "reputation.json", "clients.json"}
// The key is in neither run's output, nor in a state or cache file.
files := []string{
filepath.Join(dir, "bans.json"), filepath.Join(dir, "clients.json"),
filepath.Join(cache, "lists.json"),
}
shown := make([]string, 0, len(files)+2)
shown = append(shown, first.text(), second.text())
for _, name := range files {
data, err := os.ReadFile(filepath.Join(dir, name)) //nolint:gosec // the test's
for _, path := range files {
data, err := os.ReadFile(path) //nolint:gosec // the test's
if err != nil {
t.Fatalf("read %s: %v", name, err)
t.Fatalf("read %s: %v", path, err)
}
shown = append(shown, string(data))
}
if all := strings.Join(shown, "\n"); strings.Contains(all, bouncerKey) {
t.Errorf("the key is shown in the output or the state files:\n%s", all)
t.Errorf("the key is shown in the output or the state and cache files:\n%s", all)
}
}
@@ -894,7 +906,7 @@ func TestLookupDatabaseThatCannotBeReadStopsTheStart(t *testing.T) {
t.Fatalf("exit status %d, want 1", status)
}
want := "SWW_LOOKUP_DB_PATH cannot be read: open " + path +
want := "SWW_WAF_LOOKUP_DB_PATH cannot be read: open " + path +
": no such file or directory"
line := out.line(t, "msg", "cannot use the lookup database")
@@ -916,10 +928,10 @@ func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) {
}()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
"SWW_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(),
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
"SWW_WAF_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(),
}
out := runUntilStopped(t, env, func(url string) {
@@ -971,13 +983,13 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
}()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
"SWW_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(),
"SWW_LOG_REMOTE_BUFFER": "1",
metricsToken: token,
instanceName: instance,
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
"SWW_WAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(),
"SWW_WAF_LOG_REMOTE_BUFFER": "1",
metricsToken: token,
instanceName: instance,
}
out := runUntilStopped(t, env, func(url string) {
@@ -1041,12 +1053,12 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
dir := t.TempDir()
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: dir,
rulesDir: rules,
"SWW_ALERT_WEBHOOK_URL": webhook.url,
"SWW_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret,
instanceName: instance,
listenAddr: localhost + ":0",
stateDir: dir,
rulesDir: rules,
"SWW_WAF_ALERT_WEBHOOK_URL": webhook.url,
"SWW_WAF_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret,
instanceName: instance,
}
runUntilStopped(t, env, func(url string) {
@@ -1094,7 +1106,7 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
webhook.failing.Store(false)
env["SWW_ALLOW_NETS"] = localhost
env["SWW_WAF_ALLOW_NETS"] = localhost
env[metricsToken] = token
runUntilStopped(t, env, func(url string) {
@@ -1134,12 +1146,12 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
rulesDir: t.TempDir(),
rateLimitPerDay: "1",
// The metrics are read from 127.0.0.1, which no limit counts.
"SWW_ALLOW_NETS": localhost + "/32",
metricsToken: token,
instanceName: instance,
"SWW_ALERT_SLACK_WEBHOOK_URL": slack.url,
"SWW_ALERT_NTFY_URL": ntfy.url,
"SWW_ALERT_NTFY_TOKEN": ntfyToken,
"SWW_WAF_ALLOW_NETS": localhost + "/32",
metricsToken: token,
instanceName: instance,
"SWW_WAF_ALERT_SLACK_WEBHOOK_URL": slack.url,
"SWW_WAF_ALERT_NTFY_URL": ntfy.url,
"SWW_WAF_ALERT_NTFY_TOKEN": ntfyToken,
}
runUntilStopped(t, env, func(url string) {
@@ -1204,7 +1216,30 @@ func TestUnwritableStateDirStopsTheStart(t *testing.T) {
t.Parallel()
wantStartRefused(t, filepath.Join(t.TempDir(), "missing"),
"SWW_STATE_DIR cannot be written: ")
"SWW_WAF_STATE_DIR cannot be written: ")
}
func TestUnwritableCacheDirStopsTheStart(t *testing.T) {
t.Parallel()
ctx, stop := context.WithTimeout(t.Context(), waitLimit)
defer stop()
out := &output{}
status := run(ctx, map[string]string{
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
cacheDir: filepath.Join(t.TempDir(), "missing"),
}, out)
if status != 1 {
t.Fatalf("exit status %d, want 1", status)
}
line := out.line(t, "msg", "cannot use the state and cache files")
if message, _ := line["error"].(string); !strings.HasPrefix(message,
"SWW_WAF_CACHE_DIR cannot be written: ") {
t.Errorf("start refused with %q, want SWW_WAF_CACHE_DIR named", message)
}
}
// wantStartRefused runs sww with its state files in dir, and
@@ -1225,7 +1260,7 @@ func wantStartRefused(t *testing.T, dir, want string) {
t.Fatalf("exit status %d, want 1", status)
}
line := out.line(t, "msg", "cannot use the state files")
line := out.line(t, "msg", "cannot use the state and cache files")
message, _ := line["error"].(string)
if !strings.HasPrefix(message, want) {
@@ -1310,28 +1345,28 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
settings, _ := line["settings"].(map[string]any)
want := map[string]any{
listenAddr: localhost + ":0",
stateDir: dir,
"SWW_MODE": "enforce",
stateWriteDelay: "10s",
stateCounterInterval: "15m",
"SWW_ALLOW_NETS": "",
rateLimitExemptNets: "",
"SWW_DENY_NETS": "",
"SWW_RATE_LIMIT_PER_MINUTE": "120",
"SWW_RATE_LIMIT_PER_HOUR": "1500",
rateLimitPerDay: "6000",
"SWW_RATE_LIMIT_EXEMPT_PATHS": "",
"SWW_TAG_DENY": "",
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
"SWW_BAN_RESPONSE": "403",
"SWW_LIMIT_BAN_DURATION": "1h",
"SWW_LIMIT_BAN_REPEAT_WINDOW": "24h",
"SWW_MAX_BAN_DURATION": "7d",
"SWW_ATTACK_BAN_DURATION": "7d",
"SWW_MAX_BANS": "5000",
"SWW_BAN_SCOPE_V4_PREFIX": "32",
"SWW_RULES_ENABLED": "true",
listenAddr: localhost + ":0",
stateDir: dir,
"SWW_WAF_MODE": "enforce",
stateWriteDelay: "10s",
stateCounterInterval: "15m",
"SWW_WAF_ALLOW_NETS": "",
rateLimitExemptNets: "",
"SWW_WAF_DENY_NETS": "",
"SWW_WAF_RATE_LIMIT_PER_MINUTE": "120",
"SWW_WAF_RATE_LIMIT_PER_HOUR": "1500",
rateLimitPerDay: "6000",
"SWW_WAF_RATE_LIMIT_EXEMPT_PATHS": "",
"SWW_WAF_TAG_DENY": "",
"SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
"SWW_WAF_BAN_RESPONSE": "403",
"SWW_WAF_LIMIT_BAN_DURATION": "1h",
"SWW_WAF_LIMIT_BAN_REPEAT_WINDOW": "24h",
"SWW_WAF_MAX_BAN_DURATION": "7d",
"SWW_WAF_ATTACK_BAN_DURATION": "7d",
"SWW_WAF_MAX_BANS": "5000",
"SWW_WAF_BAN_SCOPE_V4_PREFIX": "32",
"SWW_WAF_RULES_ENABLED": "true",
}
for name, value := range want {
@@ -1531,7 +1566,7 @@ func onlyBan(t *testing.T, dir string) map[string]any {
}
// wantRefused checks that waf at url refuses a request from localhost with
// 403, the default SWW_BAN_RESPONSE.
// 403, the default SWW_WAF_BAN_RESPONSE.
func wantRefused(t *testing.T, url string) {
t.Helper()
@@ -1572,8 +1607,8 @@ func saveUntilAnswered(t *testing.T, path, content, url, from string, status int
}
}
// destination is a stand-in for SWW_ALERT_SLACK_WEBHOOK_URL or
// SWW_ALERT_NTFY_URL. It notes each request it is sent, and answers
// destination is a stand-in for SWW_WAF_ALERT_SLACK_WEBHOOK_URL or
// SWW_WAF_ALERT_NTFY_URL. It notes each request it is sent, and answers
// 200.
type destination struct {
url string
@@ -1632,7 +1667,7 @@ func (d *destination) firstPost(t *testing.T) destinationPost {
}
}
// webhook is a stand-in for SWW_ALERT_WEBHOOK_URL. It notes each alert
// webhook is a stand-in for SWW_WAF_ALERT_WEBHOOK_URL. It notes each alert
// it is sent, and answers 204, or 503 while failing.
type webhook struct {
url string
+41 -18
View File
@@ -38,6 +38,35 @@ func TestMain(m *testing.M) {
os.Exit(sww.Main(testVersion))
}
// startProcess starts cmd and returns a channel that gets its exit status. As
// the test ends, cmd is killed and the test waits for it to exit.
func startProcess(t *testing.T, cmd *exec.Cmd) <-chan int {
t.Helper()
err := cmd.Start()
if err != nil {
t.Fatalf("start: %v", err)
}
exited := make(chan int, 1)
waitReturned := make(chan struct{})
go func() {
_ = cmd.Wait()
exited <- cmd.ProcessState.ExitCode()
close(waitReturned)
}()
t.Cleanup(func() {
_ = cmd.Process.Kill()
<-waitReturned
})
return exited
}
func TestMainRunsWAF(t *testing.T) {
t.Parallel()
@@ -54,12 +83,9 @@ func TestMainRunsWAF(t *testing.T) {
t.Fatalf("find the test binary: %v", err)
}
ctx, cancel := context.WithTimeout(t.Context(), 3*waitLimit)
defer cancel()
dir := t.TempDir()
out := &output{}
cmd := exec.CommandContext(ctx, binary) //nolint:gosec // this test binary
cmd := exec.CommandContext(t.Context(), binary) //nolint:gosec // this test binary
cmd.Stdout = out
cmd.Env = append(os.Environ(),
@@ -67,20 +93,13 @@ func TestMainRunsWAF(t *testing.T) {
commandForMain+"="+wafCommand,
listenAddr+"="+localhost+":0",
stateDir+"="+dir,
cacheDir+"="+dir,
rulesDir+"="+t.TempDir(),
lookupSource+"=off")
err = cmd.Start()
if err != nil {
t.Fatalf("start: %v", err)
}
exited := startProcess(t, cmd)
t.Cleanup(func() {
_ = cmd.Process.Kill()
_ = cmd.Wait()
})
starting := out.line(t, "msg", "starting")
starting := out.lineBeforeExit(t, "starting", exited)
addr, _ := starting["address"].(string)
ask(t, addr, "/", http.StatusOK, waf.DispositionAllow)
@@ -90,9 +109,13 @@ func TestMainRunsWAF(t *testing.T) {
t.Fatalf("signal: %v", err)
}
err = cmd.Wait()
if err != nil {
t.Fatalf("exit: %v; output:\n%s", err, out.text())
select {
case status := <-exited:
if status != 0 {
t.Fatalf("exit status %d, want 0; output:\n%s", status, out.text())
}
case <-time.After(waitLimit):
t.Fatal("still running after being told to stop")
}
wantUTC(t, "the process line", starting["time"])
@@ -215,7 +238,7 @@ func runWAF(t *testing.T, env map[string]string, use func(addr string)) {
exited <- run(ctx, env, out)
}()
addr, _ := out.line(t, "msg", "starting")["address"].(string)
addr, _ := out.lineBeforeExit(t, "starting", exited)["address"].(string)
use(addr)
stop()
+6 -6
View File
@@ -1,7 +1,7 @@
package waf
// The heuristic abuseipdb. It reads SWW_ABUSEIPDB_KEY,
// SWW_ABUSEIPDB_MIN_SCORE and SWW_REPUTATION_ACTION.
// The heuristic abuseipdb. It reads SWW_WAF_ABUSEIPDB_KEY,
// SWW_WAF_ABUSEIPDB_MIN_SCORE and SWW_WAF_REPUTATION_ACTION.
import (
"context"
@@ -16,12 +16,12 @@ import (
// abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with
// the score, when its score of the client is a hit, and refuses the request
// when SWW_REPUTATION_ACTION is deny, as dnsbl does for a zone. While
// SWW_ABUSEIPDB_KEY is unset it does nothing. A client without a score is
// when SWW_WAF_REPUTATION_ACTION is deny, as dnsbl does for a zone. While
// SWW_WAF_ABUSEIPDB_KEY is unset it does nothing. A client without a score is
// checked in the background, by the request's address, if its history counts
// an offence, and the request does not wait for the answer. The score is then
// used for each address of the client. Its figures are the score and
// SWW_ABUSEIPDB_MIN_SCORE.
// SWW_WAF_ABUSEIPDB_MIN_SCORE.
type abuseIPDB struct{}
func (abuseIPDB) name() string { return "abuseipdb" }
@@ -43,7 +43,7 @@ func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
rq.abuseIPDBHit = true
rq.addTag(config.AbuseIPDBTag)
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE")
"scored by AbuseIPDB at or over SWW_WAF_ABUSEIPDB_MIN_SCORE")
if rq.h.config.ReputationAction != deny {
return nil
+1 -1
View File
@@ -51,7 +51,7 @@ func (rq *request) answerHealth() {
// answerAdmin answers a request for one of waf's own endpoints. Each
// needs a token, sent as Authorization: Bearer <token>: the metrics
// SWW_METRICS_TOKEN, the others SWW_ADMIN_TOKEN. A request without it is
// SWW_WAF_METRICS_TOKEN, the others SWW_WAF_ADMIN_TOKEN. A request without it is
// refused with 401. An endpoint whose token is unset answers 404, as any other
// path does.
func (rq *request) answerAdmin() {
+4 -4
View File
@@ -17,8 +17,8 @@ import (
)
const (
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
// adminSecret is the SWW_ADMIN_TOKEN the tests set, and adminBearer
adminToken = "SWW_WAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
// adminSecret is the SWW_WAF_ADMIN_TOKEN the tests set, and adminBearer
// how a request carries it.
adminSecret = "fedcba9876543210fedcba9876543210"
adminBearer = "Bearer " + adminSecret
@@ -337,12 +337,12 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
}
}
// adminEndpoint is a request to an endpoint SWW_ADMIN_TOKEN opens.
// adminEndpoint is a request to an endpoint SWW_WAF_ADMIN_TOKEN opens.
type adminEndpoint struct {
method, path, body string
}
// adminEndpoints returns a request to each endpoint SWW_ADMIN_TOKEN
// adminEndpoints returns a request to each endpoint SWW_WAF_ADMIN_TOKEN
// opens: listing the bans, banning otherClient for an hour, lifting the
// bans on otherClient, and asking about otherClient.
func adminEndpoints() []adminEndpoint {
+2 -2
View File
@@ -15,8 +15,8 @@ import (
)
const (
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
alertWebhookURL = "SWW_WAF_ALERT_WEBHOOK_URL"
alertMaxPerHour = "SWW_WAF_ALERT_MAX_PER_HOUR"
// alertInstance is the instance every alert of these tests gives.
alertInstance = "fsn1app1/blog"
)
+11 -11
View File
@@ -21,11 +21,11 @@ import (
// The anomaly thresholds: the prefix of a scope followed by the end of a
// count.
const (
anomalyClient = "SWW_ANOMALY_CLIENT_"
anomalyNet = "SWW_ANOMALY_NET_"
anomalyASN = "SWW_ANOMALY_ASN_"
anomalyTotal = "SWW_ANOMALY_TOTAL_"
anomalyWatch = "SWW_WATCH_"
anomalyClient = "SWW_WAF_ANOMALY_CLIENT_"
anomalyNet = "SWW_WAF_ANOMALY_NET_"
anomalyASN = "SWW_WAF_ANOMALY_ASN_"
anomalyTotal = "SWW_WAF_ANOMALY_TOTAL_"
anomalyWatch = "SWW_WAF_WATCH_"
requestsPerMinute = "REQUESTS_PER_MINUTE"
requestsPerHour = "REQUESTS_PER_HOUR"
@@ -35,9 +35,9 @@ const (
// The other anomaly settings.
const (
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
watchNets = "SWW_WATCH_NETS"
anomalyNetV4Prefix = "SWW_WAF_ANOMALY_NET_V4_PREFIX"
anomalyNetV6Prefix = "SWW_WAF_ANOMALY_NET_V6_PREFIX"
watchNets = "SWW_WAF_WATCH_NETS"
)
const (
@@ -164,9 +164,9 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
t.Parallel()
const (
allowed = "192.0.2.7" // in SWW_ALLOW_NETS
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
denied = "192.0.2.20" // in SWW_DENY_NETS
allowed = "192.0.2.7" // in SWW_WAF_ALLOW_NETS
exempt = "192.0.2.10" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
denied = "192.0.2.20" // in SWW_WAF_DENY_NETS
)
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
+257 -75
View File
@@ -1,21 +1,27 @@
package waf_test
import (
"encoding/binary"
"fmt"
"maps"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"testing"
"testing/synctest"
"time"
"github.com/prometheus/client_golang/prometheus/testutil"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
@@ -23,11 +29,13 @@ import (
)
const (
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time
// answerTimeLimit is the default of rproxy's SWW_RPROXY_TIMEOUT_MS, the time
// waf has to answer.
answerTimeLimit = 2 * time.Second
// defaultLookupWait is the default of SWW_LOOKUP_TIMEOUT.
// defaultLookupWait is the default of SWW_WAF_LOOKUP_TIMEOUT.
defaultLookupWait = 800 * time.Millisecond
// defaultReputationWait is the default of SWW_WAF_REPUTATION_TIMEOUT.
defaultReputationWait = 2 * time.Second
// The header limits SPEC.md gives.
cookieLimit = 50
headerFieldLimit = 100
@@ -173,7 +181,7 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
for _, env := range []map[string]string{
{wafMode: off},
{wafMode: "detect"},
{wafMode: block, "SWW_WAF_EXEMPT_PATHS": "/"},
{wafMode: block, "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS": "/"},
} {
t.Run(fmt.Sprint(env), func(t *testing.T) {
t.Parallel()
@@ -216,93 +224,199 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
}
}
func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
func TestTheRateLimitsDoNotCountRequestsOverTheHeaderLimits(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{rateLimitPerMinute: "1"})
ask := func() answer {
for range 2 {
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
return do(t, req)
wantDisposition(t, do(t, req), tooLarge, waf.DispositionDeny)
}
wantDisposition(t, ask(), tooLarge, waf.DispositionDeny)
wantDisposition(t, ask(), http.StatusForbidden, waf.DispositionDeny)
lines := s.out.requestLines(t, 2)
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionClientIsRateLimited)
wantDisposition(t, s.ask(client, "/"), http.StatusOK, waf.DispositionAllow)
}
func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
t.Parallel()
// The lookup database places client in Germany.
lookupDB := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
lookuptest.Write(t, lookupDB, map[string]lookuptest.Network{
client + "/32": {ASN: asnDE, ASName: asNameDE, Country: "DE"},
})
for _, tc := range []struct {
name, path, action string
name string
env map[string]string
path string
// prepare gets the server ready, at now, before the question.
prepare func(t *testing.T, server *waf.Server, now time.Time)
}{
{"a trap path", "/wp-login.php", requestlog.ActionTemporarilyBanned},
{"a block rule", "/blocked", requestlog.ActionBlockedByBlockRule},
{"SWW_WAF_DENY_NETS", map[string]string{denyNets: client}, "/", leaveAsItIs},
{"a ban", nil, "/", banClient},
{
"the country list", map[string]string{
lookupSource: fileSource, lookupDBPath: lookupDB, allowedCountries: "FR",
},
"/", leaveAsItIs,
},
{"a blocklist", map[string]string{blocklistURLs: dropURL}, "/", listClient},
{
"the CrowdSec decision list",
map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey}, "/", decideOnClient,
},
{
"a DNS blocklist", map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionDeny,
},
"/", func(_ *testing.T, server *waf.Server, _ time.Time) {
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
},
},
{
"AbuseIPDB",
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
"/", func(_ *testing.T, server *waf.Server, _ time.Time) {
// Over SWW_WAF_ABUSEIPDB_MIN_SCORE, 75 by default.
loadScores(server, map[string]int64{client: 80})
},
},
{
"SWW_WAF_TAG_DENY", map[string]string{tagLists: "vpn=" + vpnList, tagDeny: "vpn"},
"/", func(t *testing.T, server *waf.Server, _ time.Time) {
t.Helper()
loadLists(t, server, map[string][]string{vpnList: {client}})
},
},
{"the rate limits", map[string]string{rateLimitPerMinute: "1"}, "/", countClient},
{
"a trap path", map[string]string{trapPaths: trapPathList}, "/wp-login.php",
leaveAsItIs,
},
{
"a block rule", map[string]string{rulesDir: writeRules(t, testRules)}, "/blocked",
leaveAsItIs,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{
trapPaths: trapPathList, rulesDir: writeRules(t, testRules),
})
s := startWAF(t, "", tc.env)
tc.prepare(t, s.server, s.clock.Now())
req := s.question(siteA, client, tc.path)
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), http.StatusForbidden, waf.DispositionDeny)
wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action)
wantDisposition(t, do(t, req), tooLarge, waf.DispositionDeny)
wantLine(t, s.out.requestLine(t), tooLarge, requestlog.ActionRequestHeaderTooLarge)
})
}
}
func TestARequestOverTheHeaderLimitsRunsNoOtherCheck(t *testing.T) {
t.Parallel()
// Each check after the header limits leaves a mark on the log line of a
// request it reads: a tag, routewatch_tags_missing, a rule id, the
// counts, or the Core Rule Set's time.
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
lookuptest.Write(t, path, map[string]lookuptest.Network{
client + "/32": {ASN: asnDE, ASName: asNameDE, Country: "DE"},
})
s := startWAF(t, "", map[string]string{
lookupSource: fileSource, lookupDBPath: path,
tagLists: "vpn=" + vpnList,
routeWatchURL: startRouteWatch(t, map[string]standInRoute{client: {prefixDE, 64496}}),
dnsblZones: dnsblZone, reputationAction: actionLog,
rulesDir: writeRules(t, "noted path log ^/\n"),
wafMode: block,
})
loadLists(t, s.server, map[string][]string{vpnList: {client}})
loadVerdicts(s.server, map[string][]string{client: {dnsblZone}})
over := s.question(siteA, client, "/")
over.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, over), tooLarge, waf.DispositionDeny)
within := s.question(siteA, client, "/")
within.Header.Set(cookieHeader, cookies(cookieLimit))
wantDisposition(t, do(t, within), http.StatusOK, waf.DispositionAllow)
lines := s.out.requestLines(t, 2)
wantTags(t, lines[0])
wantTagsMissing(t, lines[0], "")
if lines[0].RuleIDs != nil || lines[0].Counts != (ratelimit.Counts{}) ||
lines[0].DurationCoreRuleSetMS != nil {
t.Errorf("rule ids %v, counts %+v and Core Rule Set time %v, want none",
lines[0].RuleIDs, lines[0].Counts, lines[0].DurationCoreRuleSetMS)
}
// The request within the limits shows that each check leaves its mark.
wantTags(t, lines[1],
"asn=64496", "country=DE", "dnsbl="+dnsblZone, "exploitsignature", "vpn")
wantTagsMissing(t, lines[1], "no_answer_yet")
if lines[1].RuleIDs == nil || lines[1].Counts.Minute != 1 ||
lines[1].DurationCoreRuleSetMS == nil {
t.Errorf("rule ids %v, counts %+v and Core Rule Set time %v, want each",
lines[1].RuleIDs, lines[1].Counts, lines[1].DurationCoreRuleSetMS)
}
}
func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
t.Parallel()
// The client's next request takes it over the whole limit, so it does
// not wait.
geojsURL, _ := startGeoJS(t)
s := startWAF(t, geojsURL, map[string]string{
unknownLimitPercent: "0", lookupTimeout: "1h",
unknownLimitPercent: "0", lookupTimeout: "1h", rateLimitPerMinute: "1",
})
req := s.question(siteA, fromDE, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
countClient(t, s.server, s.clock.Now())
wantDisposition(t, do(t, req), tooLarge, waf.DispositionDeny)
wantDisposition(t, s.ask(fromDE, "/"), http.StatusForbidden, waf.DispositionDeny)
line := s.out.requestLine(t)
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
if line.RateLimitPercent != nil {
t.Errorf("limit percentage %d, want none", *line.RateLimitPercent)
}
// The client is under no ban, and GeoJS places it in a country.
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, waf.DispositionAllow)
}
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
t.Parallel()
// The client's next request takes it over the whole limit, so it does
// not wait.
geojsURL, _ := startGeoJS(t)
s := startWAF(t, geojsURL, map[string]string{unknownLimitPercent: "0"})
s := startWAF(t, geojsURL, map[string]string{
unknownLimitPercent: "0", rateLimitPerMinute: "1",
})
s.server.GeoJS.Load([]lookup.Answer{{
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
Answered: s.clock.Now(), Used: s.clock.Now(),
}})
countClient(t, s.server, s.clock.Now())
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, s.ask(client, "/"), http.StatusForbidden, waf.DispositionDeny)
wantDisposition(t, do(t, req), http.StatusForbidden, waf.DispositionDeny)
wantLine(t, s.out.requestLine(t), http.StatusForbidden,
requestlog.ActionClientIsRateLimited)
line := s.out.requestLine(t)
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
if line.RateLimitPercent == nil || *line.RateLimitPercent != 0 {
t.Errorf("limit percentage %v, want 0", line.RateLimitPercent)
}
}
func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
func TestObserveModeAsksGeoJSNothingForARequestOverTheHeaderLimits(t *testing.T) {
t.Parallel()
geojsURL, _ := startGeoJS(t)
geojsURL, asked := startGeoJS(t)
s := startWAF(t, geojsURL, map[string]string{
mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
})
@@ -312,10 +426,9 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
got := do(t, req)
wantDisposition(t, got, http.StatusOK, waf.DispositionAllow)
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
if asn != asnDE || country != "DE" {
t.Errorf("answered with the AS number %q and the country %q, want %q and DE",
asn, country, asnDE)
if len(asked()) != 0 || got.header.Get("X-Client-Asn") != "" {
t.Errorf("GeoJS was asked about %v, and the answer gave the AS number %q, "+
"want neither", asked(), got.header.Get("X-Client-Asn"))
}
}
@@ -403,7 +516,7 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
action string
}{
{
name: "SWW_DENY_NETS", env: map[string]string{denyNets: client},
name: "SWW_WAF_DENY_NETS", env: map[string]string{denyNets: client},
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionClientDenied,
},
{
@@ -521,15 +634,9 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
) {
t.Parallel()
held, askedGeoJS, _ := startHeldGeoJS(t)
// A resolver that takes every query and answers none.
resolver, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", localhost+":0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = resolver.Close() })
// The lists' stand-in: one for GeoJS that answers nothing.
held, _, _ := startHeldGeoJS(t)
resolver, releaseResolver := startHeldResolver(t)
server, out, _ := newWAFWith(t, func(
params waf.Params,
@@ -537,42 +644,117 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
params.AbuseIPDBURL = silentAbuseIPDBURL
return waf.New(params)
}, held, time.Now, map[string]string{
}, silentGeoJSURL, time.Now, map[string]string{
tagDeny: "country=KP", wafMode: block,
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
dnsblZones: dnsblZone, dnsblResolver: resolver,
abuseIPDBKey: accountKey,
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
crowdSecURL: held, crowdSecKey: bouncerKey,
})
// AbuseIPDB is asked only about a client with an offence.
group := netip.MustParsePrefix(client + "/32")
server.Limiter.AddToHistory(group, time.Now(), ratelimit.Request{Attack: true})
// The lists are fetched outside the synctest bubble below: a wait on the
// network inside it would keep its clock from moving on.
go server.Lists.Run(t.Context())
status, took := askDirectly(t, server, func(req *http.Request) {
req.Header.Set("User-Agent", "test")
req.Header.Set("Accept", "*/*")
req.Host = siteA
// In the bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays says, the
// answer takes exactly as long as waf waits, however busy the host is.
// GeoJS and AbuseIPDB never answer there, without the network. The query to
// the DNS blocklist waits on the network, which stops the bubble's clock,
// but it starts only after the wait for GeoJS.
synctest.Test(t, func(t *testing.T) {
// AbuseIPDB is asked only about a client with an offence.
group := netip.MustParsePrefix(client + "/32")
server.Limiter.AddToHistory(group, time.Now(), ratelimit.Request{Attack: true})
status, took := askDirectly(t, server, func(req *http.Request) {
req.Header.Set("User-Agent", "test")
req.Header.Set("Accept", "*/*")
req.Host = siteA
})
// The wait for GeoJS decides when the answer comes.
if status != http.StatusOK || took != defaultLookupWait || took >= answerTimeLimit {
t.Errorf("answered %d after %s, want %d after %s, within %s; output:\n%s",
status, took, http.StatusOK, defaultLookupWait, answerTimeLimit, out.text())
}
// Each outside source was asked, and none has answered. GeoJS is asked
// only about the clients waiting for it, here client alone.
if requests := testutil.ToFloat64(server.Metrics.GeoJSRequests); requests != 1 {
t.Errorf("%v requests to GeoJS, want 1", requests)
}
// The resolver holds the query until it is released below, so a waf
// that waited for the query would answer only once it had failed.
queries, failures := server.DNSBL.Queries(dnsblZone), server.DNSBL.Failures(dnsblZone)
if queries != 1 || failures != 0 {
t.Errorf("%d queries to the DNS blocklist, %d failed, want 1, none failed",
queries, failures)
}
if checked := server.AbuseIPDB.Checked(); checked != 1 {
t.Errorf("%d checks with AbuseIPDB, want 1", checked)
}
// The query is answered, and the check with AbuseIPDB abandoned,
// before the bubble ends.
releaseResolver()
time.Sleep(defaultReputationWait)
})
}
if status != http.StatusOK || took >= answerTimeLimit {
t.Errorf("answered %d after %s, want %d within %s; output:\n%s",
status, took, http.StatusOK, answerTimeLimit, out.text())
// How the stand-in for the resolver reads a query and makes it its reply.
const (
// maxQueryLength is the longest query it reads.
maxQueryLength = 512
// flagsAt is where a DNS message's flags start, after its id.
flagsAt = 2
// noSuchNameFlags mark a reply, to a query that asked for recursion,
// which is available, that no such name exists.
noSuchNameFlags = 0x8183
)
// startHeldResolver starts a stand-in for the resolver the DNSBL zones are
// asked through, and returns its address and release. Each query waits until
// release is called, and is then answered that no such name exists, so that
// the zone does not list the client.
func startHeldResolver(t *testing.T) (string, func()) {
t.Helper()
conn, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", localhost+":0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
var (
released = make(chan struct{})
once sync.Once
)
go func() {
query := make([]byte, maxQueryLength)
for {
n, from, err := conn.ReadFrom(query)
if err != nil {
return // conn is closed
}
// Each outside source was asked, and none has answered. The stand-in for
// GeoJS is the lists' too, which name no address.
if !slices.Contains(askedGeoJS(), client) {
t.Errorf("GeoJS was asked about %q, not %s", askedGeoJS(), client)
}
<-released
// The query back, with the flags of the reply in place of its own.
reply := slices.Clone(query[:n])
binary.BigEndian.PutUint16(reply[flagsAt:], noSuchNameFlags)
_, _ = conn.WriteTo(reply, from)
}
}()
if queries := server.DNSBL.Queries(dnsblZone); queries != 1 {
t.Errorf("%d queries to the DNS blocklist, want 1", queries)
}
release := func() { once.Do(func() { close(released) }) }
// Run before conn is closed, so that no query is left waiting.
t.Cleanup(release)
if checked := server.AbuseIPDB.Checked(); checked != 1 {
t.Errorf("%d checks with AbuseIPDB, want 1", checked)
}
return conn.LocalAddr().String(), release
}
+6 -6
View File
@@ -1,7 +1,7 @@
package waf
// The heuristic active_ban, and the code that makes bans, which several
// heuristics share. active_ban reads SWW_MODE; the bans are the ban ledger's.
// heuristics share. active_ban reads SWW_WAF_MODE; the bans are the ban ledger's.
import (
"context"
@@ -15,7 +15,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// banResponse is a refusal answered with SWW_BAN_RESPONSE, and logged
// banResponse is a refusal answered with SWW_WAF_BAN_RESPONSE, and logged
// with action.
func (rq *request) banResponse(action string) *refusal {
return &refusal{status: rq.h.config.BanResponse, action: action}
@@ -77,7 +77,7 @@ func limitFigures(hit ratelimit.Hit) map[string]any {
// countedBytes returns the request's bytes, once it has ended, as the
// byte limits and the anomaly thresholds count them: the response's body
// bytes, the request's, or both, as SWW_BYTES_COUNT says.
// bytes, the request's, or both, as SWW_WAF_BYTES_COUNT says.
func (rq *request) countedBytes() int64 {
response, request := rq.out.bytes, rq.requestBytes
@@ -94,7 +94,7 @@ func (rq *request) countedBytes() int64 {
// banForLimit bans the client's netblock at now for a broken limit, the
// one hit names, notes the offence for the log line and counts the hit in
// the metrics. status is what the client was sent, or is sent:
// SWW_BAN_RESPONSE for a request over a rate limit, what the report says for
// SWW_WAF_BAN_RESPONSE for a request over a rate limit, what the report says for
// one whose bytes broke a byte limit, the refusal for one that broke the
// error burst. The ban's notes give the client's limit percentage for a
// rate limit or a byte limit; the error burst is not lowered. The ban sets
@@ -237,7 +237,7 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) s
// wouldAlertBan reports whether the alert for a ban on netblock for cause
// made at now would be sent. In observe mode the ban the request would
// have made is worked out only then, at most once per
// SWW_ALERT_COOLDOWN and never with no webhook set: its notes count the
// SWW_WAF_ALERT_COOLDOWN and never with no webhook set: its notes count the
// netblock's requests, which can mean going through every client.
func (rq *request) wouldAlertBan(
netblock netip.Prefix, now time.Time, cause string,
@@ -303,7 +303,7 @@ func (rq *request) netblockRequests(netblock netip.Prefix) int64 {
}
// netblock is the netblock a ban on client covers: its IPv4 address,
// widened to SWW_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
// widened to SWW_WAF_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
// counts it in.
func (h *handler) netblock(client netip.Addr) netip.Prefix {
addr := client.Unmap()
+11 -11
View File
@@ -26,7 +26,7 @@ const (
userAgent = "ban-test/1.0"
// permanent is the log line's ban_expires for a permanent ban.
permanent = "permanent"
// banResponseClose is the SWW_BAN_RESPONSE that closes the connection.
// banResponseClose is the SWW_WAF_BAN_RESPONSE that closes the connection.
banResponseClose = "close"
)
@@ -91,19 +91,19 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
}
}
// A first ban lasts SWW_LIMIT_BAN_DURATION; one within
// SWW_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
// A first ban lasts SWW_WAF_LIMIT_BAN_DURATION; one within
// SWW_WAF_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
wantExpires(breakLimit(), 10*time.Minute)
clk.advance(10*time.Minute + time.Hour)
wantExpires(breakLimit(), 30*time.Minute)
// Later than that, SWW_LIMIT_BAN_DURATION again.
// Later than that, SWW_WAF_LIMIT_BAN_DURATION again.
clk.advance(30*time.Minute + time.Hour + time.Second)
wantExpires(breakLimit(), 10*time.Minute)
clk.advance(10 * time.Minute)
wantExpires(breakLimit(), 30*time.Minute)
// 90 minutes would be longer than SWW_MAX_BAN_DURATION: the ban is
// 90 minutes would be longer than SWW_WAF_MAX_BAN_DURATION: the ban is
// permanent.
clk.advance(30 * time.Minute)
@@ -150,8 +150,8 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
// In the IPv4 cases, client breaks the limit; these two are next to it.
const (
allowed = "203.0.113.60" // in SWW_ALLOW_NETS
exempt = "203.0.113.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
allowed = "203.0.113.60" // in SWW_WAF_ALLOW_NETS
exempt = "203.0.113.50" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
)
for _, tc := range []struct {
@@ -166,7 +166,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
nil, []string{otherClient, exempt},
},
{
"the IPv4 netblock SWW_BAN_SCOPE_V4_PREFIX sets",
"the IPv4 netblock SWW_WAF_BAN_SCOPE_V4_PREFIX sets",
map[string]string{banScopeV4Prefix: "24"}, client,
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed},
},
@@ -175,7 +175,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
},
{
"the IPv6 netblock SWW_IPV6_GROUP_PREFIX sets",
"the IPv6 netblock SWW_WAF_IPV6_GROUP_PREFIX sets",
map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1",
[]string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"},
},
@@ -249,10 +249,10 @@ func TestRefusalUnderABanNamesItsHeuristicNetblockAndEnd(t *testing.T) {
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
t.Parallel()
const denied = "192.0.2.50" // in SWW_DENY_NETS
const denied = "192.0.2.50" // in SWW_WAF_DENY_NETS
for _, tc := range []struct {
setting string // "" leaves SWW_BAN_RESPONSE at its default
setting string // "" leaves SWW_WAF_BAN_RESPONSE at its default
status int // 0 is the connection closed without an answer
}{
{"", http.StatusForbidden},
+18 -17
View File
@@ -17,9 +17,9 @@ type percentage struct {
}
// biasedThresholdsSet reports whether a biased threshold can lower a
// client's limits by its lookup: SWW_ASN_BYTES_PERCENT or
// SWW_COUNTRY_BYTES_PERCENT is not empty, SWW_UNKNOWN_LIMIT_PERCENT is
// below 100, or SWW_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
// client's limits by its lookup: SWW_WAF_ASN_BYTES_PERCENT or
// SWW_WAF_COUNTRY_BYTES_PERCENT is not empty, SWW_WAF_UNKNOWN_LIMIT_PERCENT is
// below 100, or SWW_WAF_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
// then needed before its request goes on.
func biasedThresholdsSet(cfg *config.Config) bool {
return len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
@@ -31,15 +31,15 @@ func biasedThresholdsSet(cfg *config.Config) bool {
// as looked up, each "" when unknown, and the blocklists, DNSBL zones and
// AbuseIPDB that list it. Each is the lowest of those the settings give
// it, the first of them in the order below when several are lowest: the
// percentage SWW_TAG_LIMIT_PERCENT gives its asn= tag, the one the file
// SWW_ASN_LIMIT_PERCENT_URL names gives its AS number, the one
// SWW_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its
// percentage SWW_WAF_TAG_LIMIT_PERCENT gives its asn= tag, the one the file
// SWW_WAF_ASN_LIMIT_PERCENT_URL names gives its AS number, the one
// SWW_WAF_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its
// other tags, for a client without a country, unless its request skipped
// the wait for GeoJS, SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist
// lists, the percentage of SWW_BLOCKLIST_ACTION while it is limit, and
// the wait for GeoJS, SWW_WAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist
// lists, the percentage of SWW_WAF_BLOCKLIST_ACTION while it is limit, and
// for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is
// a hit, the percentage of SWW_REPUTATION_ACTION while it is limit. For
// the byte limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT
// a hit, the percentage of SWW_WAF_REPUTATION_ACTION while it is limit. For
// the byte limits, SWW_WAF_ASN_BYTES_PERCENT and SWW_WAF_COUNTRY_BYTES_PERCENT
// take the place of the first three for an AS number or a country they
// list.
func (rq *request) limitPercentages() (percentage, percentage) {
@@ -48,25 +48,25 @@ func (rq *request) limitPercentages() (percentage, percentage) {
unknown := percentage{percent: whole}
if country == "" && !rq.skippedLookupWait {
unknown = percentage{cfg.UnknownLimitPercent, "SWW_UNKNOWN_LIMIT_PERCENT"}
unknown = percentage{cfg.UnknownLimitPercent, "SWW_WAF_UNKNOWN_LIMIT_PERCENT"}
}
fetched := percentage{percent: whole}
if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed {
fetched = percentage{percent, "SWW_ASN_LIMIT_PERCENT_URL"}
fetched = percentage{percent, "SWW_WAF_ASN_LIMIT_PERCENT_URL"}
}
blocklisted := percentage{percent: whole}
if rq.blocklisted && cfg.BlocklistAction == "limit" {
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWW_BLOCKLIST_ACTION"}
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWW_WAF_BLOCKLIST_ACTION"}
}
reputationListed := percentage{percent: whole}
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"}
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_WAF_REPUTATION_ACTION"}
}
const tagSetting = "SWW_TAG_LIMIT_PERCENT"
const tagSetting = "SWW_WAF_TAG_LIMIT_PERCENT"
asnRequests := lowest(given(cfg.TagLimitPercent, asnTag(asn), tagSetting), fetched)
countryRequests := given(cfg.TagLimitPercent, countryTag(country), tagSetting)
@@ -81,11 +81,12 @@ func (rq *request) limitPercentages() (percentage, percentage) {
asnBytes, countryBytes := asnRequests, countryRequests
if _, listed := cfg.ASNBytesPercent[asn]; listed {
asnBytes = given(cfg.ASNBytesPercent, asn, "SWW_ASN_BYTES_PERCENT")
asnBytes = given(cfg.ASNBytesPercent, asn, "SWW_WAF_ASN_BYTES_PERCENT")
}
if _, listed := cfg.CountryBytesPercent[country]; listed {
countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT")
countryBytes = given(cfg.CountryBytesPercent, country,
"SWW_WAF_COUNTRY_BYTES_PERCENT")
}
return lowest(asnRequests, countryRequests, otherTags, unknown, blocklisted,
+7 -7
View File
@@ -22,10 +22,10 @@ import (
// The biased thresholds.
const (
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
tagLimitPercent = "SWW_WAF_TAG_LIMIT_PERCENT"
asnBytesPercent = "SWW_WAF_ASN_BYTES_PERCENT"
countryBytesPercent = "SWW_WAF_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWW_WAF_UNKNOWN_LIMIT_PERCENT"
)
const (
@@ -34,7 +34,7 @@ const (
asnDEHalf = asnDE + ":50"
asnDEQuarter = asnDE + ":25"
countryDEHalf = "de:50"
// The same, as SWW_TAG_LIMIT_PERCENT names fromDE's tags.
// The same, as SWW_WAF_TAG_LIMIT_PERCENT names fromDE's tags.
asnTagDEHalf = "asn=64496:50"
asnTagDEQuarter = "asn=64496:25"
countryTagDEHalf = "country=DE:50"
@@ -240,7 +240,7 @@ func TestLowestPercentageApplies(t *testing.T) {
noCountry, "25 from " + unknownLimitPercent,
},
{
// SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
// SWW_WAF_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
"the AS number's, for a client without a country",
map[string]string{tagLimitPercent: "asn=64500:25"},
noCountry, "25 from " + tagLimitPercent,
@@ -505,7 +505,7 @@ func wantPercent(t *testing.T, what string, percent *int64, setting, want string
}
// percentText gives a limit percentage and the setting that gave it as
// text, such as "50 from SWW_TAG_LIMIT_PERCENT", or none when both are
// text, such as "50 from SWW_WAF_TAG_LIMIT_PERCENT", or none when both are
// left out.
func percentText(percent *int64, setting string) string {
switch {
+3 -3
View File
@@ -1,6 +1,6 @@
package waf
// The heuristic blocklists. It reads SWW_BLOCKLIST_ACTION, and the blocklists
// The heuristic blocklists. It reads SWW_WAF_BLOCKLIST_ACTION, and the blocklists
// through the reputation lists.
import (
@@ -11,7 +11,7 @@ import (
)
// blocklists notes the blocklists that list the client, as noteListed does,
// and refuses the request when SWW_BLOCKLIST_ACTION is deny. Being limit, it
// and refuses the request when SWW_WAF_BLOCKLIST_ACTION is deny. Being limit, it
// lowers the client's limits instead (see limitPercentages), and being log, it
// does nothing more. Its figure is the blocklists that list the client.
type blocklists struct{}
@@ -35,7 +35,7 @@ func (blocklists) check(_ context.Context, rq *request, _ time.Time) *verdict {
// refusedByBlocklists reports whether the heuristic blocklists refuses a
// client, given listedBy, the blocklists that list it: it does when one lists
// it and SWW_BLOCKLIST_ACTION is deny.
// it and SWW_WAF_BLOCKLIST_ACTION is deny.
func (rq *request) refusedByBlocklists(listedBy []string) bool {
return len(listedBy) > 0 && rq.h.config.BlocklistAction == deny
}
+1 -1
View File
@@ -1,6 +1,6 @@
package waf
// The heuristic byte_limits. It reads SWW_BYTES_COUNT, and the byte limits
// The heuristic byte_limits. It reads SWW_WAF_BYTES_COUNT, and the byte limits
// and biased thresholds through the limiter and limitPercentages.
import (
+8 -8
View File
@@ -19,13 +19,13 @@ import (
// The byte limit settings.
const (
bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
bytesCount = "SWW_BYTES_COUNT"
bytesLimitPerMinute = "SWW_WAF_BYTES_LIMIT_PER_MINUTE"
bytesLimitPerHour = "SWW_WAF_BYTES_LIMIT_PER_HOUR"
bytesLimitPerDay = "SWW_WAF_BYTES_LIMIT_PER_DAY"
bytesCount = "SWW_WAF_BYTES_COUNT"
)
// The values of SWW_BYTES_COUNT.
// The values of SWW_WAF_BYTES_COUNT.
const (
countResponse = "response"
countRequest = "request"
@@ -40,7 +40,7 @@ const (
// byteLimit is the byte limit these tests set, as a setting: a request
// with a body and its answer, 100 bytes, go over it.
byteLimit = "99"
// minuteBytes is limit_hit for SWW_BYTES_LIMIT_PER_MINUTE.
// minuteBytes is limit_hit for SWW_WAF_BYTES_LIMIT_PER_MINUTE.
minuteBytes = "minute_bytes"
)
@@ -227,8 +227,8 @@ func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) {
t.Parallel()
const (
allowed = "192.0.2.7" // in SWW_ALLOW_NETS
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
allowed = "192.0.2.7" // in SWW_WAF_ALLOW_NETS
exempt = "192.0.2.10" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
)
s, _ := startWithAnswers(t, map[string]string{
+1 -1
View File
@@ -43,7 +43,7 @@ func scheme(r *http.Request, isQuestion bool) string {
// clientGroup is the client a request is counted toward: its IPv4
// address, or its IPv6 group, the netblock its IPv6 address is in of the
// length SWW_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser
// length SWW_WAF_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser
// usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4.
func (h *handler) clientGroup(addr netip.Addr) netip.Prefix {
addr = addr.Unmap()
+16 -15
View File
@@ -1,7 +1,7 @@
package waf
// The heuristic core_rule_set. It reads SWW_WAF_MODE, SWW_WAF_EXEMPT_PATHS
// and SWW_WAF_ANOMALY_THRESHOLD.
// The heuristic core_rule_set. It reads SWW_WAF_CORE_RULE_SET_MODE,
// SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS and SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
import (
"context"
@@ -14,13 +14,13 @@ import (
)
// coreRuleSet inspects the request with the Core Rule Set, unless
// SWW_WAF_MODE is off or SWW_WAF_EXEMPT_PATHS exempts its path, as
// pathExempt decides, and notes the rules it matched and its score in the
// log line, and the rules in the metrics. A score at or over
// SWW_WAF_ANOMALY_THRESHOLD is a match: it adds the tag exploitsignature,
// raises the core_rule_set_match alert, and in block mode refuses the request with 403,
// which is an offence its client's history counts. Its figures are the score
// and the threshold.
// SWW_WAF_CORE_RULE_SET_MODE is off or SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS
// exempts its path, as pathExempt decides, and notes the rules it matched and
// its score in the log line, and the rules in the metrics. A score at or over
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD is a match: it adds the tag
// exploitsignature, raises the core_rule_set_match alert, and in block mode
// refuses the request with 403, which is an offence its client's history
// counts. Its figures are the score and the threshold.
type coreRuleSet struct{}
func (coreRuleSet) name() string { return "core_rule_set" }
@@ -67,10 +67,10 @@ func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
}
// alertCoreRuleSetMatch raises the core_rule_set_match alert for the request,
// which the Core Rule Set scored at result, at or over SWW_WAF_ANOMALY_THRESHOLD. Its
// detail gives the rule ids, the score, the method and the path with the
// query, and, for a request that is not refused for it, the mode: detect,
// or observe in observe mode.
// which the Core Rule Set scored at result, at or over
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD. Its detail gives the rule ids, the
// score, the method and the path with the query, and, for a request that is
// not refused for it, the mode: detect, or observe in observe mode.
func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
detail := map[string]any{
"rule_ids": result.RuleIDs,
@@ -94,7 +94,8 @@ func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
ASN: rq.line.ASN,
ASName: rq.line.ASName,
Country: rq.line.Country,
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
Detail: detail,
Reason: "scored by the Core Rule Set at or over " +
"SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
Detail: detail,
})
}
+13 -12
View File
@@ -14,13 +14,13 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// The Core Rule Set's settings the tests set, besides SWW_WAF_MODE, and
// The Core Rule Set's settings the tests set, besides SWW_WAF_CORE_RULE_SET_MODE, and
// its two modes that inspect requests.
const (
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
wafAnomalyThreshold = "SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD"
wafDisabledRules = "SWW_WAF_CORE_RULE_SET_DISABLED_RULES"
wafExemptPaths = "SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS"
wafBodyLimit = "SWW_WAF_CORE_RULE_SET_BODY_LIMIT"
block = "block"
detect = "detect"
)
@@ -28,7 +28,7 @@ const (
// formData is the type of a form's body.
const formData = "application/x-www-form-urlencoded"
// sizeLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies, 1K as a
// sizeLimit is SWW_WAF_CORE_RULE_SET_BODY_LIMIT in the tests that read bodies, 1K as a
// setting.
const (
sizeLimit = 1 << 10
@@ -37,7 +37,7 @@ const (
// sqlInjection asks for / with an SQL injection in its query, which only
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
// SWW_WAF_ANOMALY_THRESHOLD.
// SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD.
const sqlInjection = "/?id=1'%20OR%20'1'='1"
// wantCoreRuleSet checks the request log line's core_rule_set_rule_ids and
@@ -177,7 +177,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
) {
t.Parallel()
const allowed = "192.0.2.60" // in SWW_ALLOW_NETS
const allowed = "192.0.2.60" // in SWW_WAF_ALLOW_NETS
s, _, _ := startWithClock(t, "", map[string]string{
wafMode: block,
@@ -186,7 +186,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
rulesDir: writeRules(t, testRules),
})
// A client in SWW_ALLOW_NETS, and a path SWW_WAF_EXEMPT_PATHS
// A client in SWW_WAF_ALLOW_NETS, and a path SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS
// exempts, are not inspected.
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantCoreRuleSet(t, line, nil)
@@ -205,7 +205,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
requestlog.ActionBlockedByBlockRule)
wantCoreRuleSet(t, line, nil)
// With SWW_WAF_MODE off, no request is.
// With SWW_WAF_CORE_RULE_SET_MODE off, no request is.
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantCoreRuleSet(t, line, nil)
@@ -522,8 +522,9 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
Host: appHost,
Client: netip.MustParseAddr(client),
Netblock: netip.MustParsePrefix(client + "/32"),
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
Detail: detail,
Reason: "scored by the Core Rule Set at or over " +
"SWW_WAF_CORE_RULE_SET_ANOMALY_THRESHOLD",
Detail: detail,
})
if queue.Suppressed() != 1 {
+3 -3
View File
@@ -1,6 +1,6 @@
package waf
// The heuristic countries. It reads SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
// The heuristic countries. It reads SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES.
import (
"context"
@@ -10,7 +10,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// countries refuses a request when SWW_EXCLUSIVELY_ALLOWED_COUNTRIES is set and
// countries refuses a request when SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES is set and
// does not hold the client's country as it was looked up. A client without a
// country, or whose country cannot be found, is refused. Its figures are the
// country and the setting that refused it.
@@ -29,7 +29,7 @@ func (countries) check(_ context.Context, rq *request, _ time.Time) *verdict {
return &verdict{
action: requestlog.ActionCountryDenied,
figures: map[string]any{
"country": country, "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
"country": country, "setting": "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES",
},
}
}
+6 -6
View File
@@ -38,7 +38,7 @@ func TestCountryLists(t *testing.T) {
[]string{fromKP, unplaced},
},
{
"with a country in SWW_TAG_DENY",
"with a country in SWW_WAF_TAG_DENY",
map[string]string{tagDeny: denyKP, allowedCountries: "de,fr"},
[]string{fromKP, unplaced},
},
@@ -100,11 +100,11 @@ func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)
wantHeuristic(t, line, "countries",
`{"country": "KP", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
`{"country": "KP", "setting": "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied)
wantHeuristic(t, line, "countries",
`{"country": "", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
`{"country": "", "setting": "SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
}
func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
@@ -262,11 +262,11 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
action string
}{
{
"not in SWW_ALLOW_NETS", "",
"not in SWW_WAF_ALLOW_NETS", "",
http.StatusForbidden, requestlog.ActionCountryDenied,
},
{
"in SWW_ALLOW_NETS", "10.0.0.7,fd00::/8",
"in SWW_WAF_ALLOW_NETS", "10.0.0.7,fd00::/8",
http.StatusOK, requestlog.ActionForward,
},
} {
@@ -296,7 +296,7 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
// each in an AS of its own, and no other address. It returns its URL, and
// what returns the addresses it has been asked about. A test that needs
// the stand-in to be asked or to answer sets SWW_LOOKUP_TIMEOUT to an
// the stand-in to be asked or to answer sets SWW_WAF_LOOKUP_TIMEOUT to an
// hour, whether or not a request waits for the answer: on the default, a
// hold-up of the test process can abandon the request to the stand-in, and
// leave the client unknown.
+1 -1
View File
@@ -1,6 +1,6 @@
package waf
// The heuristic crowdsec. It reads SWW_CROWDSEC_LAPI_URL, and the decisions
// The heuristic crowdsec. It reads SWW_WAF_CROWDSEC_LAPI_URL, and the decisions
// through the reputation lists.
import (
+4 -4
View File
@@ -16,10 +16,10 @@ import (
// The CrowdSec settings, and the tests' engine, which is never asked: each
// test puts in the copy of its decision list, at decisionsURL, that it
// needs, as reputation.json would at start.
// needs, as lists.json would at start.
const (
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
crowdSecURL = "SWW_WAF_CROWDSEC_LAPI_URL"
crowdSecKey = "SWW_WAF_CROWDSEC_LAPI_KEY"
lapi = "http://crowdsec.example:8080"
decisionsURL = lapi + "/v1/decisions"
bouncerKey = "crowdsec-key-0123456789abcdef"
@@ -182,7 +182,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
// loadDecisions puts into server's lists the copy of the decision list at
// decisionsURL, answer, the engine's answer, fetched at fetched, as
// reputation.json would at start.
// lists.json would at start.
func loadDecisions(
t *testing.T, server *waf.Server, fetched time.Time, answer string,
) {
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff. Show more