Measured follow-up to the AppSec section. Virtual patching blocks 7 of 41 probes with near-zero false positives; the OWASP CRS raises that to 27 of 41 but, at default paranoia, blocks six legitimate workflows (git push, Forgejo issues and file contents, Outline documents, ntfy publish, n8n webhooks). crowdsec/appsec-rules/crs-route-exclusions.yaml disables the SecLang/Coraza engine (ctl:ruleEngine=Off) for exactly those application routes, matched by host + URI, so virtual patching still runs there. crowdsec/appsec-configs/ appsec-tuned.yaml wires it in before the CRS rule. Measured with tuning: zero false positives, still 27 of 41 blocked on non-excluded routes, about +2 ms per request, ~15-40 MB more RAM. Trap: RemoveInBandRuleByName in a pre_eval hook does not disable CRS (it is SecLang, not a native rule); ctl:ruleEngine in phase 1 is the working lever, and the exclusion rule must be listed before the CRS rule. The stack runs vpatch+generic by default (zero false positives, no per-app tuning). The CRS recipe ships as a documented opt-in in crowdsec/README.md.
8.5 KiB
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)
-
Build the Caddy image with the modules (
caddy/Dockerfile), elsewhere, and load it on the box. -
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 crowdsecRun
crowdsec -tfirst. A failed restart means no Local API; the firewall keeps its current sets, but no new decisions arrive. -
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,7422The container reaches the host as
host.docker.internal(extra_hosts: host-gatewayincompose.yml, which is172.17.0.1). From the internet both ports stay filtered. The mesh interfacewt0is allowed wholesale by episode 5, so mesh peers can reach both; each still needs an API key. -
cscli bouncers add caddy-bouncer -o rawand put the key into.envasCROWDSEC_BOUNCER_KEY, thendocker 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.
Optional: OWASP CRS for generic coverage, and the tuning it needs (episode 13)
Virtual patching (the default above) only blocks known-CVE signatures. Measured
against 41 real probes: 7 blocked. Generic SQLi, XSS, Log4Shell-via-header,
Struts OGNL and path traversal all passed, because there is no rule for them,
only per-CVE ones. base-config is just the Coraza body-processor setup, not
the OWASP Core Rule Set.
The CRS is in the hub as an appsec-rule. Adding it raises coverage to 27 of 41:
cscli appsec-rules install crowdsecurity/crs
But at default paranoia the CRS blocks legitimate use. Measured false positives with CRS on and nothing else changed: git clone/push over HTTPS (the pack negotiation body), Forgejo issues and file contents whose text contains code or SQL, Outline documents with a code block, ntfy publish bodies, n8n webhook bodies. Six normal workflows, all 403. For a stack where people push git and write wiki pages, unusable as-is.
The fix is per-route exclusion, not turning CRS off globally.
appsec-rules/crs-route-exclusions.yaml is a small SecLang rule that runs in
phase 1 and sets ctl:ruleEngine=Off for exactly the legitimate application
routes (Forgejo git smart-HTTP and REST API, Outline API, ntfy publish, n8n
webhooks), matched by REQUEST_HEADERS:Host + REQUEST_URI. ctl:ruleEngine
only affects the Coraza/SecLang engine, so the native virtual-patching and
generic rules still run on those routes; only the generic CRS body/URL
inspection is skipped there.
appsec-configs/appsec-tuned.yaml wires it up: base-config, the exclusion rule
(must come before crs so the ctl takes effect in phase 1), crs, then
vpatch and generic. Deploy it instead of the default:
cp appsec-rules/crs-route-exclusions.yaml /etc/crowdsec/appsec-rules/
cp appsec-configs/appsec-tuned.yaml /etc/crowdsec/appsec-configs/
# point acquis.d/appsec.yaml at selfhostedops/appsec-tuned instead of crowdsecurity/appsec-default
crowdsec -t && systemctl restart crowdsec
Measured with the tuned config: 0 false positives across all the workflows above, still 27 of 41 probes blocked on the non-excluded routes. The cost is precise and worth stating out loud: on an excluded route (say the Forgejo API), generic SQLi/XSS now passes to the app (virtual patching still covers known CVEs); on a non-excluded host (vault, status) the full CRS applies. Latency added over vpatch: about +2 ms time-to-first-byte per request. RAM: CRS adds roughly 15 to 40 MB over vpatch (RSS settles near 185 MB, peaks higher right after loading the rule data).
Traps found while tuning:
RemoveInBandRuleByName("crowdsecurity/crs")in apre_evalhook does not disable CRS: the bouncer name does not match the compiled SecLang rules. Usectl:ruleEngineinside SecLang instead.- The exclusion rule must be listed before
crowdsecurity/crsin the config, or the phase-1ctlruns too late. - Every new app or new public path needs its own exclusion. A missed one blocks a real workflow, and it fails only after login, so it is easy to miss.
- Authenticated Vaultwarden and Outline sync with real encrypted payloads were not exercised against CRS here; test your own before trusting CRS on those hosts.
What this repo runs by default: vpatch + generic (the appsec-default
config, zero false positives, no per-app tuning). The CRS files above are the
measured opt-in for anyone who wants generic coverage and will budget the
per-route tuning.