Compare commits
4 commits
d147f21490
...
696f3e311d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
696f3e311d | ||
|
|
22300d7a28 | ||
|
|
26a7da0495 | ||
|
|
88670f028f |
13 changed files with 402 additions and 9 deletions
|
|
@ -78,3 +78,9 @@ RUNNER_UUID=
|
|||
# The runner still refuses any action or any target that is not on its own
|
||||
# hard-coded allowlists, so a leaked token cannot widen the blast radius.
|
||||
OPS_ACTIONS_TOKEN=
|
||||
|
||||
# --- Episode 12: CrowdSec -------------------------------------------------
|
||||
# The Caddy bouncer's key for the CrowdSec Local API on the host:
|
||||
# cscli bouncers add caddy-bouncer -o raw
|
||||
# Without it Caddy still starts, but blocks nothing and logs auth errors.
|
||||
CROWDSEC_BOUNCER_KEY=
|
||||
|
|
|
|||
22
Caddyfile
22
Caddyfile
|
|
@ -5,6 +5,16 @@
|
|||
email {$ACME_EMAIL}
|
||||
# episode 10: rate_limit has to run before reverse_proxy hands the request on
|
||||
order rate_limit before basic_auth
|
||||
# episode 12: CrowdSec decides first (banned IP -> 403), then the AppSec WAF
|
||||
# checks the request itself. Both talk to CrowdSec on the host.
|
||||
order crowdsec first
|
||||
order appsec after crowdsec
|
||||
crowdsec {
|
||||
api_url http://host.docker.internal:8090
|
||||
api_key {$CROWDSEC_BOUNCER_KEY}
|
||||
ticker_interval 10s
|
||||
appsec_url http://host.docker.internal:7422
|
||||
}
|
||||
}
|
||||
|
||||
# episode 10: one snippet, imported into every site. JSON access log (fail2ban
|
||||
|
|
@ -22,8 +32,15 @@
|
|||
header -Server
|
||||
}
|
||||
|
||||
# episode 12: blocklist + WAF. Imported everywhere except vpn. (NetBird gRPC streams).
|
||||
(crowdsec) {
|
||||
crowdsec
|
||||
appsec
|
||||
}
|
||||
|
||||
git.{$DOMAIN} {
|
||||
import common
|
||||
import crowdsec
|
||||
# episode 10: throttle login attempts (10 per minute per source IP)
|
||||
rate_limit {
|
||||
zone git_login {
|
||||
|
|
@ -41,11 +58,13 @@ git.{$DOMAIN} {
|
|||
|
||||
wiki.{$DOMAIN} {
|
||||
import common
|
||||
import crowdsec
|
||||
reverse_proxy outline:3000
|
||||
}
|
||||
|
||||
vault.{$DOMAIN} {
|
||||
import common
|
||||
import crowdsec
|
||||
# episode 10: the admin panel is a setup tool, not a public page (was 200)
|
||||
@admin path /admin*
|
||||
respond @admin 404
|
||||
|
|
@ -65,12 +84,14 @@ vault.{$DOMAIN} {
|
|||
|
||||
status.{$DOMAIN} {
|
||||
import common
|
||||
import crowdsec
|
||||
reverse_proxy uptime-kuma:3001
|
||||
}
|
||||
|
||||
# Push notifications (episode 6). Websockets and long polling pass through as-is.
|
||||
ntfy.{$DOMAIN} {
|
||||
import common
|
||||
import crowdsec
|
||||
reverse_proxy ntfy:80
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +101,7 @@ ntfy.{$DOMAIN} {
|
|||
# one door that has to stay open - and it is exactly one door wide.
|
||||
n8n.{$DOMAIN} {
|
||||
import common
|
||||
import crowdsec
|
||||
@webhooks path /webhook/* /webhook-test/* /webhook-waiting/*
|
||||
handle @webhooks {
|
||||
reverse_proxy n8n:5678
|
||||
|
|
|
|||
73
HARDENING.md
73
HARDENING.md
|
|
@ -86,6 +86,12 @@ advice from episode 1 got wrong:
|
|||
the ban in INPUT; Caddy's traffic is forwarded, so a bot "banned" in INPUT
|
||||
keeps getting 404s (measured). Set `chain = DOCKER-USER` as a plain jail
|
||||
parameter and the same bot runs into a refused connection instead.
|
||||
- **Once passwords are off, `mode = normal` goes half blind** (episode 12).
|
||||
Key-only root attempts log as `Connection closed by authenticating user
|
||||
root [preauth]`, which the normal sshd filter ignores. Measured: 192 attempts
|
||||
from one IP in 24 h, zero seen. `mode = aggressive` in the jail counts them;
|
||||
it also counts the second line of each invalid-user attempt, so maxretry 5
|
||||
bans after roughly 3 tries.
|
||||
|
||||
Operational note: `ufw reload` flushes `DOCKER-USER` and takes fail2ban's ban
|
||||
jumps with it, run `systemctl restart fail2ban` afterwards to restore them.
|
||||
|
|
@ -374,3 +380,70 @@ agent doing the whole job against seven tools gets the counts right often but
|
|||
not always; splitting the work into three narrow roles, each with one skill
|
||||
file (`agents/skills/`), trades more tokens for a more reliable report. The
|
||||
numbers are in the lab notes.
|
||||
|
||||
## Episode 12: CrowdSec next to fail2ban, same box, same logs
|
||||
|
||||
A viewer asked for it: does CrowdSec replace fail2ban? We ran both on the demo
|
||||
box and gave them the same 24 hours of real traffic (fail2ban live, CrowdSec by
|
||||
replaying the identical sshd log). Setup and traps: `crowdsec/README.md`.
|
||||
|
||||
**What fail2ban caught in 24 h** (password auth already off since episode 10):
|
||||
1,458 SSH login attempts from 218 IPs. fail2ban banned **4 IPs** (6 bans).
|
||||
Two blind spots, both measured:
|
||||
|
||||
- *Slow bots.* One IP tried 273 times, one attempt every ~4.5 minutes. Never 5
|
||||
in 10 minutes, so never banned.
|
||||
- *Root without a password path.* `Connection closed by authenticating user
|
||||
root [preauth]` is not a failure in the default `mode = normal` of the sshd
|
||||
filter. One IP made 192 of those attempts and fail2ban did not see a single
|
||||
one. `mode = aggressive` matches 2,192 of 2,345 lines instead of 696.
|
||||
|
||||
**What CrowdSec detected on the same log:** 23 IPs, including the slow one.
|
||||
**What it would have banned: 1 IP.** 22 of the 23 came from
|
||||
`ssh-time-based-bf`, and that scenario ships with `remediation: false`: it
|
||||
raises an alert (and a community signal) but no decision. Out of the box,
|
||||
CrowdSec *sees* more and *blocks* less than fail2ban on this traffic. If you
|
||||
want the slow ones banned, add a profile for that scenario instead of editing
|
||||
it: a modified hub scenario is "tainted", its signals stop counting, and a
|
||||
non-contributing engine drops to the 3,000-IP Lite blocklist.
|
||||
|
||||
**The community blocklist:** 15,000 IPs on day one (after a restart, see the
|
||||
README). Of the 218 IPs that tried to log in during the previous 24 h, **109
|
||||
were already on it**, but those 109 made only **19 %** of the attempts. The list
|
||||
knows the one-shot scanners; the persistent bots that hammer a single box were
|
||||
mostly not on it (1 of the top 10). None of the 4 IPs fail2ban banned were
|
||||
listed. The two tools overlap less than you would think.
|
||||
|
||||
**The firewall bouncer and Docker, again.** In its default iptables mode the
|
||||
bouncer only hooks `INPUT`: a banned IP gets a timeout on SSH and still 200 on
|
||||
every Docker-published port. `DOCKER-USER` fixes it until `ufw reload` flushes
|
||||
the chain (it takes fail2ban's jump with it; nothing restores either). The
|
||||
bouncer's **nftables mode** hooks `input` and `forward` in its own table, blocks
|
||||
the Docker ports, and survived `ufw reload` untouched.
|
||||
|
||||
**Controlled attack, both watching:**
|
||||
|
||||
| | fail2ban | CrowdSec |
|
||||
|---|---|---|
|
||||
| invalid user, 1 try/s | banned after 5 tries (~5 s) | detected after 3 tries, firewall drop ~3 s later |
|
||||
| `root`, key-only, 1 try/s | never | detected after 6 tries, drop ~3 s later |
|
||||
| 12 web probes on vault (`/.env` answers 422) | no ban (filter counts 401/403/404/429 only) | detected after 3 probes, drop ~3 s later |
|
||||
|
||||
fail2ban acts the moment it matches (REJECT, "connection refused"). CrowdSec
|
||||
decides fast but the bouncer polls every 10 s, so a few more requests slip
|
||||
through (DROP, timeout).
|
||||
|
||||
**AppSec (WAF) in Caddy:** every exploit request (`/.env`, `/.git/config`, the
|
||||
PHPUnit RCE) got a **403 on the first try**, while normal requests in between
|
||||
stayed 200. Two blocks and the IP is banned at the firewall. Cost: +1 to 2 ms
|
||||
median time to first byte, one more round-trip to the host per request.
|
||||
|
||||
**What it costs on a 4 GB box:** CrowdSec ~200 MB RSS, firewall bouncer
|
||||
~30 MB, fail2ban ~50 MB. The engine, the bouncers and the hub are MIT or Apache
|
||||
licensed and free; the 15k community list is free as long as you share
|
||||
signals. Bigger lists and the console's premium tier are paid.
|
||||
|
||||
**Verdict for this stack:** run both. fail2ban reacts instantly to the loud
|
||||
ones on the lines you tell it to watch; CrowdSec adds the slow bots (as
|
||||
alerts), the blocklist and the WAF, at 4x the memory. Neither replaces the
|
||||
other on these numbers.
|
||||
|
|
|
|||
|
|
@ -247,6 +247,12 @@ security updates, pinned image tags, offsite backups. The ready-to-paste configs
|
|||
from episode 10 (ufw `after.rules`, fail2ban jails and filter, sshd drop-in) live
|
||||
in **[hardening/](hardening/)**.
|
||||
|
||||
Episode 12 adds CrowdSec next to fail2ban (not instead of it): community
|
||||
blocklist, a firewall bouncer in nftables mode that Docker cannot bypass, and an
|
||||
AppSec WAF inside Caddy. Install notes and the traps we measured are in
|
||||
**[crowdsec/](crowdsec/)**. Note the Caddy image now needs the key
|
||||
`CROWDSEC_BOUNCER_KEY` in `.env`.
|
||||
|
||||
---
|
||||
|
||||
MIT — do whatever. If it saved you money, a YouTube subscription is the price:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
# episode 10: the stock caddy:2-alpine image cannot rate limit. The directive
|
||||
# lives in a plugin (github.com/mholt/caddy-ratelimit) that has to be compiled
|
||||
# in with xcaddy - there is no runtime "enable" for it. Two stages so the final
|
||||
# image stays the same small alpine, just with our own caddy binary.
|
||||
# in with xcaddy - there is no runtime "enable" for it.
|
||||
# episode 12: same trick for CrowdSec. The bouncer (http) and the AppSec
|
||||
# handler (appsec) are plugins too, pinned to a release tag instead of @main.
|
||||
FROM caddy:2-builder-alpine AS builder
|
||||
RUN xcaddy build --with github.com/mholt/caddy-ratelimit
|
||||
RUN xcaddy build \
|
||||
--with github.com/mholt/caddy-ratelimit \
|
||||
--with github.com/hslatman/caddy-crowdsec-bouncer/http@v0.14.1 \
|
||||
--with github.com/hslatman/caddy-crowdsec-bouncer/appsec@v0.14.1
|
||||
|
||||
FROM caddy:2-alpine
|
||||
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||
|
|
|
|||
|
|
@ -1,16 +1,23 @@
|
|||
# caddy/ (episode 10)
|
||||
# caddy/ (episode 10, extended in episode 12)
|
||||
|
||||
Custom Caddy image with the rate limiting plugin compiled in.
|
||||
Custom Caddy image with the rate limiting plugin, the CrowdSec bouncer and the
|
||||
CrowdSec AppSec handler compiled in (see `crowdsec/README.md`).
|
||||
|
||||
The stock `caddy:2-alpine` has no `rate_limit` directive; it is not a runtime
|
||||
flag, the module must be built into the binary with `xcaddy`. `compose.yml`
|
||||
carries both `image:` and `build: ./caddy`, so `docker compose build caddy`
|
||||
produces `selfhostedops/caddy:ratelimit` locally and `docker compose up -d`
|
||||
produces `selfhostedops/caddy:crowdsec` locally and `docker compose up -d`
|
||||
uses it.
|
||||
|
||||
Do not build on a small box while the stack is running - a Go build can push a
|
||||
2 GB machine into OOM and take the live services down with it. Build it
|
||||
elsewhere (a bigger machine, or a throwaway host) and move it over:
|
||||
|
||||
docker build -t selfhostedops/caddy:ratelimit ./caddy
|
||||
docker save selfhostedops/caddy:ratelimit | ssh root@box docker load
|
||||
docker build -t selfhostedops/caddy:crowdsec ./caddy
|
||||
docker save selfhostedops/caddy:crowdsec | ssh root@box docker load
|
||||
|
||||
Measured in episode 12: the build with all three modules took 60 s on a 12 GB
|
||||
build host, the image is 158 MB (the rate-limit-only image was 154 MB).
|
||||
|
||||
The modules are pinned to the release tag (`@v0.14.1`), not `@main` as the
|
||||
upstream README suggests, so a rebuild next month gives the same binary.
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ volumes:
|
|||
|
||||
services:
|
||||
caddy:
|
||||
image: selfhostedops/caddy:ratelimit # episode 10: own image with caddy-ratelimit
|
||||
image: selfhostedops/caddy:crowdsec # episode 12: caddy-ratelimit + CrowdSec bouncer + AppSec
|
||||
build: ./caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
|
@ -68,6 +68,9 @@ services:
|
|||
environment:
|
||||
DOMAIN: ${DOMAIN}
|
||||
ACME_EMAIL: ${ACME_EMAIL}
|
||||
CROWDSEC_BOUNCER_KEY: ${CROWDSEC_BOUNCER_KEY} # episode 12, see crowdsec/README.md
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway" # episode 12: CrowdSec runs on the host
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ./logs/caddy:/var/log/caddy # episode 10: JSON access log on the host for fail2ban
|
||||
|
|
|
|||
199
crowdsec/README.md
Normal file
199
crowdsec/README.md
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
# 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.
|
||||
|
||||
## 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 a per-route exclusion. `appsec-rules/crs-route-exclusions.yaml` is a
|
||||
small SecLang rule that runs in phase 1 and, 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`,
|
||||
removes the CRS anomaly blocking rules with
|
||||
`ctl:ruleRemoveById=949110,ctl:ruleRemoveById=959100` (949110 is the CRS
|
||||
inbound anomaly-score blocker, 959100 the outbound one). CRS still scores the
|
||||
request but no longer blocks it on those routes.
|
||||
|
||||
Do **not** use `ctl:ruleEngine=Off` for this. It turns the whole Coraza engine
|
||||
off for the request, and measured on the box that also disables the native
|
||||
CrowdSec virtual-patching rules: on a route excluded with `ruleEngine=Off`, a
|
||||
`.env` probe returned 404 (no WAF at all) instead of 403. With
|
||||
`ctl:ruleRemoveById` the engine stays on, so virtual patching keeps firing:
|
||||
measured, `.env`, the PHPUnit RCE and `.git/config` still return 403 on the
|
||||
excluded routes, while generic SQLi/XSS now pass (that is the point).
|
||||
|
||||
Rule order does not matter for `ctl:ruleRemoveById` (a phase-1 ctl removes the
|
||||
phase-2 blocker regardless of file position); the exclusion sits first only for
|
||||
readability. It would matter for `ruleEngine=Off`, one more reason to avoid it.
|
||||
|
||||
`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 every workflow
|
||||
above (git clone/push, Forgejo issues and file contents, Outline documents with
|
||||
an attack-looking code block, ntfy publish, n8n webhooks, and a full
|
||||
authenticated Vaultwarden register/login/sync/cipher-create), still **27 of 41**
|
||||
probes blocked on the non-excluded routes. The cost is precise: on an excluded
|
||||
route (say the Forgejo API) generic SQLi/XSS now passes to the app, while
|
||||
virtual patching still catches known CVEs there, and 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 measured settled: +9 MB over vpatch after five minutes (vpatch ~184 MB, CRS+exclusions ~193 MB). The ~25-45 MB people cite is a load-time peak, not the steady cost.
|
||||
|
||||
Traps found while tuning:
|
||||
- `RemoveInBandRuleByName("crowdsecurity/crs")` in a `pre_eval` hook does **not**
|
||||
disable CRS: the bouncer name does not match the compiled SecLang rules
|
||||
(measured, globally and per route, SQLi stayed 403). Use `ctl:` inside SecLang.
|
||||
Docs: https://docs.crowdsec.net/docs/appsec/hooks
|
||||
- `ctl:ruleEngine=Off` is too broad: it disables virtual patching on the route
|
||||
too. Use `ctl:ruleRemoveById` for the CRS blocking rules and keep the engine on.
|
||||
- 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.
|
||||
|
||||
**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.
|
||||
10
crowdsec/acquis.d/appsec.yaml
Normal file
10
crowdsec/acquis.d/appsec.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# episode 12: the AppSec (WAF) endpoint the Caddy bouncer asks on every request.
|
||||
# Listens on all addresses; ufw only lets the Docker "proxy" network in
|
||||
# (see crowdsec/README.md). Needs the appsec collections AND the rules listed
|
||||
# in the README, or crowdsec refuses to start.
|
||||
appsec_configs:
|
||||
- crowdsecurity/appsec-default
|
||||
labels:
|
||||
type: appsec
|
||||
listen_addr: 0.0.0.0:7422
|
||||
source: appsec
|
||||
7
crowdsec/acquis.d/caddy.yaml
Normal file
7
crowdsec/acquis.d/caddy.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# episode 12: CrowdSec reads the same JSON access log fail2ban reads.
|
||||
# The path is the host side of the ./logs/caddy volume in compose.yml.
|
||||
filenames:
|
||||
- /opt/selfhostedops-stack/logs/caddy/access.log
|
||||
labels:
|
||||
type: caddy
|
||||
source: file
|
||||
16
crowdsec/appsec-configs/appsec-tuned.yaml
Normal file
16
crowdsec/appsec-configs/appsec-tuned.yaml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
name: selfhostedops/appsec-tuned
|
||||
default_remediation: ban
|
||||
# ep13: vpatch + generic (nativ) plus OWASP CRS (SecLang). Die lokale Regel
|
||||
# crs-route-exclusions entfernt je legitimer Route nur die CRS-Anomalie-
|
||||
# Blockregeln (ctl:ruleRemoveById), die native vpatch-Abdeckung bleibt dort
|
||||
# erhalten. Reihenfolge zu crowdsecurity/crs ist egal (Phase 1 vor Phase 2),
|
||||
# der Uebersicht halber steht die Ausnahme oben.
|
||||
inband_rules:
|
||||
- crowdsecurity/base-config
|
||||
- selfhostedops/crs-route-exclusions
|
||||
- crowdsecurity/crs
|
||||
- crowdsecurity/vpatch-*
|
||||
- crowdsecurity/generic-*
|
||||
outofband_rules:
|
||||
- crowdsecurity/experimental-*
|
||||
- crowdsecurity/appsec-generic-test
|
||||
28
crowdsec/appsec-rules/crs-route-exclusions.yaml
Normal file
28
crowdsec/appsec-rules/crs-route-exclusions.yaml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
name: selfhostedops/crs-route-exclusions
|
||||
# ep13: entfernt je legitimer Route NUR die CRS-Anomalie-Blockregeln (949110
|
||||
# inbound, 959100 outbound), sodass das generische CRS dort nicht mehr sperrt.
|
||||
# Die Coraza-Engine bleibt an (ruleEngine On), deshalb laufen die nativen
|
||||
# CrowdSec-vpatch-Regeln auf diesen Routen weiter (bekannte CVEs bleiben
|
||||
# geschuetzt). NICHT ctl:ruleEngine=Off benutzen, das schaltet auch vpatch ab.
|
||||
# Die Regel muss VOR crowdsecurity/crs stehen (Phase 1).
|
||||
seclang_rules:
|
||||
# Forgejo git smart-HTTP (clone/push), binaerer Pack-Body
|
||||
- |
|
||||
SecRule REQUEST_HEADERS:Host "@beginsWith git." "id:900101,phase:1,pass,nolog,chain,msg:'ep13 crs-off git smart-http'"
|
||||
SecRule REQUEST_URI "@rx (git-upload-pack|git-receive-pack|/info/refs\?service=)" "ctl:ruleRemoveById=949110,ctl:ruleRemoveById=959100"
|
||||
# Forgejo REST-API: Issues, Datei-Inhalte
|
||||
- |
|
||||
SecRule REQUEST_HEADERS:Host "@beginsWith git." "id:900102,phase:1,pass,nolog,chain,msg:'ep13 crs-off forgejo api'"
|
||||
SecRule REQUEST_URI "@beginsWith /api/v1/" "ctl:ruleRemoveById=949110,ctl:ruleRemoveById=959100"
|
||||
# Outline Dokument-API
|
||||
- |
|
||||
SecRule REQUEST_HEADERS:Host "@beginsWith wiki." "id:900103,phase:1,pass,nolog,chain,msg:'ep13 crs-off outline api'"
|
||||
SecRule REQUEST_URI "@beginsWith /api/" "ctl:ruleRemoveById=949110,ctl:ruleRemoveById=959100"
|
||||
# ntfy Publish (POST /<topic>)
|
||||
- |
|
||||
SecRule REQUEST_HEADERS:Host "@beginsWith ntfy." "id:900104,phase:1,pass,nolog,chain,msg:'ep13 crs-off ntfy publish'"
|
||||
SecRule REQUEST_METHOD "@streq POST" "ctl:ruleRemoveById=949110,ctl:ruleRemoveById=959100"
|
||||
# n8n Webhook-Pfade
|
||||
- |
|
||||
SecRule REQUEST_HEADERS:Host "@beginsWith n8n." "id:900105,phase:1,pass,nolog,chain,msg:'ep13 crs-off n8n webhook'"
|
||||
SecRule REQUEST_URI "@beginsWith /webhook" "ctl:ruleRemoveById=949110,ctl:ruleRemoveById=959100"
|
||||
12
crowdsec/firewall-bouncer.yaml
Normal file
12
crowdsec/firewall-bouncer.yaml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# episode 12: the keys changed in /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml
|
||||
# (the package writes the rest, including api_url and api_key).
|
||||
#
|
||||
# nftables mode instead of the iptables default. The bouncer then owns its
|
||||
# own tables (ip crowdsec, ip6 crowdsec6) with hooks on input AND forward at
|
||||
# priority -10, so it drops before Docker's FORWARD rules, and `ufw reload`
|
||||
# does not touch it. In iptables mode you must add DOCKER-USER to
|
||||
# iptables_chains, and every `ufw reload` silently removes the rule again.
|
||||
mode: nftables
|
||||
nftables_hooks:
|
||||
- input
|
||||
- forward
|
||||
Loading…
Add table
Add a link
Reference in a new issue