Episode 12/13: CrowdSec next to fail2ban, and an AppSec WAF in Caddy

Measured on the demo box against the same 24h of real traffic.

Episode 12 (detection, blocklist, firewall):
- crowdsec/ with install notes and the traps: port 8080 clash, empty first
  blocklist pull, replay forensic-mode dropping events (FIFO workaround),
  ssh-time-based-bf shipping remediation:false, the AppSec collections that
  refuse to start without extra rules.
- firewall bouncer in nftables mode (input + forward), which blocks
  Docker-published ports and survives ufw reload; iptables/DOCKER-USER does not.
- HARDENING.md episode 12 section with the numbers: fail2ban 4 banned IPs vs
  CrowdSec 23 detected / 1 blocked, blocklist overlap 50% of IPs but 19% of
  attempts, and the mode=normal blind spot for key-only root attempts.

Episode 13 (AppSec WAF):
- caddy/Dockerfile now builds the CrowdSec bouncer and AppSec handler alongside
  rate-limit, pinned to v0.14.1.
- Caddyfile imports a crowdsec snippet (bouncer + appsec) on every site except
  vpn; compose passes CROWDSEC_BOUNCER_KEY and host.docker.internal.
- Measured: virtual patching blocks known CVEs only (7 of 41 probes, near-zero
  false positives); OWASP CRS raises that to 27 of 41 but breaks git push and
  wiki editing at default paranoia. Default stays vpatch+generic.

fail2ban config unchanged. No secrets or real domains/IPs in tracked files.
This commit is contained in:
Maximilian Grimm 2026-09-16 20:34:18 +02:00
parent d147f21490
commit 88670f028f
11 changed files with 280 additions and 9 deletions

121
crowdsec/README.md Normal file
View file

@ -0,0 +1,121 @@
# crowdsec/ (episode 12)
CrowdSec next to fail2ban on the same box, both reading the same logs. This
folder holds the files that differ from a stock install. Everything was
measured on the demo box; the numbers are in `HARDENING.md` (episode 12).
CrowdSec runs on the **host**, not in the stack: it reads the sshd journal
directly and drives the host firewall. Caddy talks to it over the Docker
bridge.
## Install (Debian 13)
```
curl -fsSL https://install.crowdsec.net | sh
apt install crowdsec
```
**Port 8080 trap.** The Local API defaults to `127.0.0.1:8080`. On this stack
the NetBird dashboard already publishes `127.0.0.1:8080`, so the installer
prints one line ("Not attempting to start crowdsec, port 8080 is already
used") and leaves the service **inactive**. Move it:
```
sed -i 's#listen_uri: 127.0.0.1:8080#listen_uri: 0.0.0.0:8090#' /etc/crowdsec/config.yaml
sed -i 's#url: http://127.0.0.1:8080#url: http://127.0.0.1:8090#' /etc/crowdsec/local_api_credentials.yaml
```
(`0.0.0.0` only because the Caddy container has to reach it, see below. If you
skip the Caddy bouncer, keep `127.0.0.1:8090`.)
**First start pulls nothing.** The very first community-blocklist pull ran
before the scenario list was registered ("scenario list is empty, will not
pull yet", 0 entries). A `systemctl restart crowdsec` pulled 15,000 IPs a
minute later. Check with `cscli decisions list --origin CAPI | wc -l`.
## Log sources and collections
```
cscli collections install crowdsecurity/caddy
cp acquis.d/caddy.yaml /etc/crowdsec/acquis.d/
```
The installer already adds `crowdsecurity/sshd` and a journald source for
`ssh.service`.
## Firewall bouncer
```
apt install crowdsec-firewall-bouncer-iptables
```
The package registers itself with the Local API. Then apply
`firewall-bouncer.yaml` (mode `nftables`) and restart it. Why not the
default iptables mode: it only inserts into `INPUT`. SSH is blocked, but every
Docker-published port (80, 443, 2222) stays reachable for a banned IP, the same
hole as fail2ban in episode 10. Adding `DOCKER-USER` fixes that until the next
`ufw reload`, which flushes the chain and nobody puts the rule back.
## Caddy bouncer and AppSec (WAF)
1. Build the Caddy image with the modules (`caddy/Dockerfile`), elsewhere, and
load it on the box.
2. AppSec collections, plus the rules the default config references but the
collections do not install (without them crowdsec **does not start**:
"no appsec-rules found for pattern crowdsecurity/experimental-*", then the
same for `generic-*`):
```
cscli collections install crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
cscli appsec-rules install crowdsecurity/experimental-no-user-agent \
crowdsecurity/generic-freemarker-ssti crowdsecurity/generic-wordpress-uploads-listing \
crowdsecurity/generic-wordpress-uploads-php
cp acquis.d/appsec.yaml /etc/crowdsec/acquis.d/
crowdsec -t && systemctl restart crowdsec
```
Run `crowdsec -t` first. A failed restart means no Local API; the firewall
keeps its current sets, but no new decisions arrive.
3. Let the Docker network in, and only that network:
```
ufw allow proto tcp from 172.18.0.0/16 to 172.17.0.1 port 8090,7422
```
The container reaches the host as `host.docker.internal` (`extra_hosts:
host-gateway` in `compose.yml`, which is `172.17.0.1`). From the internet
both ports stay filtered. The mesh interface `wt0` is allowed wholesale by
episode 5, so mesh peers can reach both; each still needs an API key.
4. `cscli bouncers add caddy-bouncer -o raw` and put the key into `.env` as
`CROWDSEC_BOUNCER_KEY`, then `docker compose up -d caddy`.
The `vpn.` site does not import the `crowdsec` snippet: it carries the NetBird
gRPC streams, and a WAF round-trip on every stream message is not what you want
between your peers.
## Replaying old logs (and the forensic-mode trap)
`crowdsec -dsn file:///path/to/log -type syslog` is documented as replay mode.
With CrowdSec 1.8.1 it shut down one second after reading the file and dropped
every event still in the pipeline: 47 or 49 alerts on two identical runs, none
after 02:07 of a 24 h log. Feeding the same file through a FIFO that stays open
60 s after the last line gives the full, repeatable result (133 alerts, twice):
```
mkfifo /tmp/replay.fifo
(cat sshd.log; sleep 60) > /tmp/replay.fifo &
crowdsec -c replay.yaml -order-event -dsn file:///tmp/replay.fifo -type syslog
```
Use a separate config (`replay.yaml`) with its own database, its own
`listen_uri` and **no `online_client`**, or the replayed alerts land in your
live database and go out as signals.
## What goes out to CrowdSec
With the default config, every alert from an unmodified hub scenario is pushed
as a signal: scenario name and hash, timestamp, your machine id, and **the
attacking IP**. Custom or modified ("tainted") scenarios and manual decisions
are not pushed unless you enroll in the console. Your own penetration tests are
real alerts too: switch `sharing: false` under `api.server.online_client`
while you attack your own box, or you report your test machine.