From 6e8027705dbcd5253ee60c55db2b2fd29926314c Mon Sep 17 00:00:00 2001 From: Thibault Koechlin Date: Mon, 31 Aug 2026 17:45:07 +0200 Subject: [PATCH 1/4] bot detection support for the skill --- .claude-plugin/marketplace.json | 6 +- .claude-plugin/plugin.json | 9 +- CHANGELOG.md | 17 + CLAUDE.md | 5 + skills/crowdsec/SKILL.md | 19 +- .../appsec/bot-detection/configure.md | 107 ++++++ .../appsec/bot-detection/customize.md | 360 ++++++++++++++++++ .../references/appsec/bot-detection/deploy.md | 262 +++++++++++++ .../appsec/bot-detection/overview.md | 149 ++++++++ .../appsec/bot-detection/troubleshoot.md | 268 +++++++++++++ .../crowdsec/references/appsec/configure.md | 14 +- skills/crowdsec/references/appsec/deploy.md | 3 + skills/crowdsec/references/appsec/overview.md | 5 +- .../references/appsec/troubleshoot.md | 6 +- .../configure/bouncers/web-servers.md | 18 +- .../crowdsec/references/configure/profiles.md | 4 + 16 files changed, 1234 insertions(+), 18 deletions(-) create mode 100644 skills/crowdsec/references/appsec/bot-detection/configure.md create mode 100644 skills/crowdsec/references/appsec/bot-detection/customize.md create mode 100644 skills/crowdsec/references/appsec/bot-detection/deploy.md create mode 100644 skills/crowdsec/references/appsec/bot-detection/overview.md create mode 100644 skills/crowdsec/references/appsec/bot-detection/troubleshoot.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4c0a59c..c61d428 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,14 +5,14 @@ "url": "https://github.com/crowdsecurity" }, "metadata": { - "description": "CrowdSec skills and tooling — operational automation for installing, configuring, and debugging CrowdSec, plus a Service API skill for the premium Console cloud API." + "description": "CrowdSec skills and tooling \u2014 operational automation for installing, configuring, and debugging CrowdSec, plus a Service API skill for the premium Console cloud API." }, "plugins": [ { "name": "crowdsec", "source": "./", - "description": "CrowdSec skills: an operational skill (engine, cscli, hub, bouncers, WAF/AppSec across bare-metal, Docker, Kubernetes) and a Service API skill (premium Console cloud API — blocklists, allowlists, firewall integrations, metrics, decisions).", - "version": "0.2.3", + "description": "CrowdSec skills: an operational skill (engine, cscli, hub, bouncers, WAF/AppSec across bare-metal, Docker, Kubernetes) and a Service API skill (premium Console cloud API \u2014 blocklists, allowlists, firewall integrations, metrics, decisions).", + "version": "0.3.0", "author": { "name": "CrowdSec", "url": "https://github.com/crowdsecurity" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 702ad71..93a8f56 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,11 +1,11 @@ { "name": "crowdsec", - "version": "0.2.3", + "version": "0.3.0", "skills": [ "./skills/crowdsec", "./skills/crowdsec-service-api" ], - "description": "CrowdSec skills: an operational skill (install/configure/operate/debug the engine, cscli, bouncers, WAF across bare-metal/Docker/Kubernetes) and a Service API skill (drive the premium Console cloud API — blocklists, allowlists, firewall integrations, metrics, decisions).", + "description": "CrowdSec skills: an operational skill (install/configure/operate/debug the engine, cscli, bouncers, WAF and bot detection across bare-metal/Docker/Kubernetes) and a Service API skill (drive the premium Console cloud API \u2014 blocklists, allowlists, firewall integrations, metrics, decisions).", "author": { "name": "CrowdSec", "url": "https://github.com/crowdsecurity" @@ -25,6 +25,9 @@ "sapi", "blocklist", "integration", - "cloud" + "cloud", + "bot-detection", + "bot-management", + "challenge" ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 528597a..ee3905b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `references/appsec/bot-detection/` — bot detection / AppSec challenge mode (CrowdSec 1.8): + `overview.md` (PoW + fingerprint model, challenge envelope, event vocabulary, bouncer + support), `deploy.md` (bundle choice, glob acquisition, bouncer wiring, four-step smoke + test), `configure.md` (the `challenge:` block, multi-instance `master_secret`, difficulty + levels), `customize.md` (hook/helper matrix, exemptions, `MatchKnownBot` datafiles, + fingerprint scoring, custom scenarios), `troubleshoot.md` (10 symptoms + metrics reading). + +### Changed +- `SKILL.md` — bot-detection routing rows, boundary row, trigger phrases in `description`, + and a new **Step 1.6 — Feature compatibility** table holding minimum engine versions and + feature prerequisites in one place. +- `references/appsec/{overview,configure,deploy,troubleshoot}.md` — `challenge` action, + `on_challenge` / `on_challenge_submit` hooks, and pointers into `bot-detection/`. +- `references/configure/bouncers/web-servers.md` — per-bouncer bot-detection capability column. +- `references/configure/profiles.md` — note that `challenge` is not a LAPI decision type. + ## [0.2.3] - 2026-05-29 ## [0.2.2] - 2026-05-26 diff --git a/CLAUDE.md b/CLAUDE.md index 2e01d92..5fdc0c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,10 @@ If you are uncertain about any fact, statistic, date, or piece of technical info Depth and recipes belong in the reference docs, not in `SKILL.md`. - **Cover every environment.** Each command or recipe carries its systemd / docker / k8s variant, matching the existing convention. If some command or recipe is irrelevant to some variant it should be noted. +- **Version-gate in one place.** Minimum engine versions and feature prerequisites live in + `SKILL.md` § Step 1.6 — Feature compatibility, one row per feature. Reference docs describe + behavior and point there; they do not sprinkle "1.8+" through the prose. Adding a + version-gated feature means adding a row in the same change. - **Anchor to canonical docs.** Each reference doc cites the upstream CrowdSec docs URL it derives from. Claims trace to canonical documentation, not to memory. @@ -49,6 +53,7 @@ All depth lives in `references//`, organized by the axis that fits the are | `configure/` | **config domain** | `acquisition`, `hub`, `profiles`, `notifications`, `allowlists`; platforms merged inline. `configure/bouncers/` nests one level by **service type** (`firewall`, `web-servers`). | | `operate/` | **task** | `health-check`, `upgrades`, `multi-server`. | | `appsec/` | **lifecycle** | `overview` → `deploy` → `configure` → `troubleshoot` (the WAF/AppSec feature silo). | +| `appsec/bot-detection/` | **lifecycle** | `overview` → `deploy` → `configure` → `customize` → `troubleshoot`. Bot detection (challenge mode) ships inside the AppSec component but is its own feature with its own install, config and failure modes, so it gets its own silo and its own routing rows — not a section of the WAF docs. `customize` is the extra step: authoring exemptions and scoring rules is the bulk of the work. | | `debug/` | **kind** | `common/` (`triage`, `errors`, `platform-gotchas`) + `symptoms/` (`parsing`, `no-alerts`, `not-blocked`). Feature troubleshooting is *routed to* the feature's own dir (e.g. AppSec → `appsec/troubleshoot.md`), not duplicated under debug/. | | `migrate/` | **source product** | `from-fail2ban`. | | `scripts/` | — | helper scripts (`diagnose.sh`, `check-verification.py`); stdlib/bash only, runnable in static checks. | diff --git a/skills/crowdsec/SKILL.md b/skills/crowdsec/SKILL.md index 8d38070..2fc971b 100644 --- a/skills/crowdsec/SKILL.md +++ b/skills/crowdsec/SKILL.md @@ -1,6 +1,6 @@ --- name: crowdsec -description: Use when the user is installing, configuring, operating, or debugging CrowdSec — including cscli, LAPI/CAPI, hub collections, parsers/scenarios/whitelists deployment, bouncers (firewall, nginx, traefik, caddy), WAF (AppSec component) deployment, profiles, notifications, upgrades, and fail2ban migration. Covers bare-metal/systemd, Docker, Kubernetes/Helm, and CrowdSec Console enrollment. This is an operational skill — it does not author WAF rules, scenarios, or parsers. +description: Use when the user is installing, configuring, operating, or debugging CrowdSec — including cscli, LAPI/CAPI, hub collections, parsers/scenarios/whitelists deployment, bouncers (firewall, nginx, traefik, caddy), WAF (AppSec component) deployment, bot detection / AppSec challenge mode (blocking headless browsers and scrapers, allowing verified crawlers), profiles, notifications, upgrades, and fail2ban migration. Covers bare-metal/systemd, Docker, Kubernetes/Helm, and CrowdSec Console enrollment. This is an operational skill — it does not author WAF rules, scenarios, or parsers. --- # CrowdSec — operations, deployment, configuration, and debugging @@ -17,6 +17,7 @@ thing. This skill uses both interchangeably. | Configure acquisition, hub, profiles, notifications | this skill | | Install and wire a bouncer (firewall, nginx, traefik, caddy) | this skill | | Deploy the WAF (AppSec component) | this skill | +| Deploy and tune bot detection (AppSec challenge mode) | this skill | | Debug "logs not parsing" / "no alerts" / "bouncer not blocking" | this skill | | Migrate from fail2ban | this skill | | **Write** a parser, scenario, or WAF (AppSec) rule | the `crowdsec-local-mcp` mcp | @@ -96,6 +97,20 @@ onto the official repo first: [references/operate/upgrades.md](./references/oper Hub. If it's far behind `version.crowdsec.net/latest`, pull a newer tag ([references/operate/upgrades.md](./references/operate/upgrades.md) happy path). +## Step 1.6 — Feature compatibility + +Some features only exist past a given engine version, and a few also need a capable bouncer. +Check here before debugging "the config is right but nothing happens" — on an older engine the +answer is an upgrade, not a config change. + +| Feature | Min engine | Also needs | Docs | +|---|---|---|---| +| Bot detection (AppSec challenge mode) — *alpha* | **1.8.0** | A bot-detection-capable bouncer (nginx, OpenResty, HAProxy SPOA, Traefik, Envoy) and a host that can run WASM in compiler mode | [references/appsec/bot-detection/](./references/appsec/bot-detection/) | +| Glob patterns in `appsec_configs` | **1.8.0** | — | [references/appsec/bot-detection/deploy.md](./references/appsec/bot-detection/deploy.md) § 2 | + +Version-gated behaviour is recorded here rather than repeated in each reference doc — when a +feature is added, give it a row instead of sprinkling "x.y+" through the prose. + ## Step 2 — Detect the intent | Cue from user | Go to | @@ -117,6 +132,7 @@ Hub. If it's far behind `version.crowdsec.net/latest`, pull a newer tag | "caddy bouncer", "caddy module / xcaddy" | [references/configure/bouncers/web-servers.md](./references/configure/bouncers/web-servers.md) § Caddy | | "wrong source IP", "real client IP", "behind Cloudflare / reverse proxy / NPM", "X-Forwarded-For", "everyone shows as the proxy IP" | [references/configure/bouncers/web-servers.md](./references/configure/bouncers/web-servers.md) — per-bouncer real-IP/trusted-proxy sections | | "AppSec", "WAF", "virtual patching", "block by request shape" | [references/appsec/](./references/appsec/) — overview, deploy, configure, troubleshoot | +| "bot detection", "challenge mode", "JS challenge", "proof of work", "block headless browsers / puppeteer / selenium / scrapers", "let Googlebot through" | [references/appsec/bot-detection/](./references/appsec/bot-detection/) — overview, deploy, configure, customize, troubleshoot | | "Console", "enroll", "share signals" | [references/install/console.md](./references/install/console.md) | | "upgrade", "back up", "roll back", "new version", "tainted items after upgrade" | [references/operate/upgrades.md](./references/operate/upgrades.md) | | "old/outdated version", "`cscli` command or flag missing", "hub item won't install", "behavior doesn't match the docs", "installed from the distro package" | [references/operate/upgrades.md](./references/operate/upgrades.md) § Detect & fix an outdated / distro-packaged install (see **Step 1.5** above) | @@ -131,6 +147,7 @@ Hub. If it's far behind `version.crowdsec.net/latest`, pull a newer tag | **Debug — by symptom** · "decision exists but not blocked" | [references/debug/symptoms/not-blocked.md](./references/debug/symptoms/not-blocked.md) | | **Debug — by symptom** · "bouncer blocks everything", "locked out of all services", "every request 403 after adding the bouncer" | [references/debug/symptoms/not-blocked.md](./references/debug/symptoms/not-blocked.md) § 7 — Inverse symptom | | **Debug — by feature** · AppSec/WAF not blocking, false positives, captcha | [references/appsec/troubleshoot.md](./references/appsec/troubleshoot.md) | +| **Debug — by feature** · challenge never served, endless challenge loop, real crawlers being challenged, everything blocked after enabling bot detection | [references/appsec/bot-detection/troubleshoot.md](./references/appsec/bot-detection/troubleshoot.md) | | "switch from fail2ban" | [references/migrate/from-fail2ban.md](./references/migrate/from-fail2ban.md) *(TODO — stub)* | For anything debug-shaped, the first move is almost always: diff --git a/skills/crowdsec/references/appsec/bot-detection/configure.md b/skills/crowdsec/references/appsec/bot-detection/configure.md new file mode 100644 index 0000000..34d3401 --- /dev/null +++ b/skills/crowdsec/references/appsec/bot-detection/configure.md @@ -0,0 +1,107 @@ +--- +verified: + - date: 2026-08-31 + version: "1.8.0" + env: systemd + notes: "challenge: block via overlay — master_secret silences the warning, cookie_ttl 2h applied; difficulty levels + invalid-level error" +--- + +# Tune the challenge runtime + +Canonical docs: + +The `challenge:` block is a **top-level key in any appsec-config** (a sibling of `name:` and +`inband:`, not nested under them). Every loaded appsec-config contributes, merged field by +field with last-non-nil winning — so never edit the hub files. Ship a small overlay instead: + +```yaml +# /etc/crowdsec/appsec-configs/mycorp-overlay.yaml +name: mycorp/appsec-bot-challenge-overlay + +challenge: + master_secret: "efb7789044ed092ae90c139848be7687652920064dccc40db1eeb926a8050930" + cookie_ttl: 2h +``` + +and list it by name in `appsec_configs:` — the `crowdsecurity/appsec-bot-*` glob will not +pick up your namespace ([./deploy.md](./deploy.md) § 2). + +## Keys + +| Key | Default | What it does | +|---|---|---| +| `master_secret` | random, ephemeral | Root of every derived signing key and the cookie-sealing key. Hex ≥64 chars (preferred) or a passphrase ≥32 bytes. | +| `key_rotation_interval` | `5m` | How often the per-epoch signing key advances. Minimum 30s. | +| `max_live_epochs` | `3` | Past epochs still accepted, so a rotation does not invalidate in-flight submissions. A client has `(max_live_epochs + 1) × key_rotation_interval` to solve. | +| `cookie_ttl` | `12h` | Success-cookie lifetime. Sealed under a separate long-lived key, so key rotation does **not** invalidate issued cookies. | +| `crypto_obfuscation_pool_size` | `1` | Distinct obfuscations of the per-epoch key module, for per-visitor variance. Costs roughly 5s CPU per variant per rotation. | +| `max_cookie_size` | `4096` | Byte cap on the sealed cookie, enforced on seal and open. Bounds an over-allocation DoS. Raise only for non-browser clients. | +| `spent_set_max_entries` | `1000000` | Replay-protection LRU — a solved challenge cannot be submitted twice. | +| `log_level` | inherits global | Verbosity of the challenge runtime only. See [./troubleshoot.md](./troubleshoot.md) § 8. | + +Confirm what actually took effect — the runtime logs its resolved settings at startup: + +``` +level=info msg="WAF challenge runtime initialized" cookie_ttl=2h0m0s crypto_pool_size=1 max_cookie_len=4096 module=challenge pow_difficulty=20 rotation_interval=5m0s +``` + +## Multi-instance — the one that bites + +With no `master_secret`, each instance generates a random one at startup and every instance +rejects the others' cookies, so clients re-challenge on every request that lands on a +different node. Restarts have the same effect on a single node. The engine warns: + +``` +level=warning msg="no master secret configured for the WAF challenge runtime; generated an ephemeral random secret. Distributed (multi-WAF) deployments MUST configure a shared master_secret in the appsec config; single-instance deployments will see outstanding challenge cookies invalidated on restart." module=challenge +``` + +Behind a load balancer, or on more than one AppSec instance, set it — and set +`key_rotation_interval` and `max_live_epochs` identically everywhere, since keys derive from +both. + +```bash +openssl rand -hex 32 +``` + +Rotating the secret: roll the new value out to **every** instance inside one `cookie_ttl` +window, then restart each. During the rollout, clients holding old cookies are challenged +once more; they are not blocked. + +| Change | Invalidates | +|---|---| +| `master_secret` | Issued cookies **and** in-flight challenges | +| `key_rotation_interval`, `max_live_epochs` | In-flight challenges only | +| `cookie_ttl` | Nothing — applies to newly issued cookies | +| `crypto_obfuscation_pool_size` | Nothing — applies at the next rotation tick | + +Treat the secret as a credential: a Kubernetes `Secret` projected into the log-processor's +appsec-config, or a Docker secret / bind-mounted file — not a value committed next to the +manifest. The appsec-config itself is a plain file at +`/etc/crowdsec/appsec-configs/` (systemd), a mounted path in the same directory (docker), or +an entry in the log-processor ConfigMap (k8s). + +## Difficulty + +Proof-of-work cost in leading zero bits. Calibrated for a low-end 2-core phone; a desktop is +roughly 20× faster. + +| Level | Bits | Low-end mobile | Desktop | +|---|---|---|---| +| `disabled` | 0 | instant | instant | +| `low` | 18 | ~0.5s | ~0.03s | +| `medium` **(default)** | 20 | ~2s | ~0.10s | +| `high` | 22 | ~8s | ~0.41s | +| `impossible` | 256 | never solvable — a deliberate hard block, rejected server-side with no reason leaked | + +There is no `challenge:` key for the default level. Set it per request from a hook — +`SetChallengeDifficulty("high")` in `pre_eval` or `on_challenge` +([./customize.md](./customize.md)). An unknown level is not a startup error; it fails per +request at runtime and the request is still challenged at the default: + +``` +level=error msg="unable to apply appsec pre_eval[inband] expr: unknown challenge difficulty \"extreme\" (expected disabled, low, medium, high, or impossible)" +``` + +Raising difficulty is a poor bot deterrent on its own — a determined scraper has more CPU +than a phone. Use it to slow down abuse of a specific expensive route, and rely on +fingerprint scoring to actually identify bots. diff --git a/skills/crowdsec/references/appsec/bot-detection/customize.md b/skills/crowdsec/references/appsec/bot-detection/customize.md new file mode 100644 index 0000000..a931c44 --- /dev/null +++ b/skills/crowdsec/references/appsec/bot-detection/customize.md @@ -0,0 +1,360 @@ +--- +verified: + - date: 2026-08-31 + version: "1.8.0" + env: systemd + notes: "overlay deploy loop, ExemptFromChallenge, GrantChallengeCookie 307+TTL, SetChallengeDifficulty, MatchKnownBot + mandatory data: block, hook-placement errors, scoring points read from installed appsec-bot-challenge-scoring 0.2 (hub config is the source of truth), custom scenario on both evt.Meta and evt.Parsed paths" +--- + +# Write your own bot-detection config + +Canonical docs: · +[hooks reference](https://docs.crowdsec.net/docs/next/appsec/bot_detection/hooks) + +Two jobs: **let legitimate traffic through**, and **catch bots the shipped scoring misses**. +Both are done with your own appsec-config — never by editing the hub files, which a +`cscli hub upgrade` will overwrite. + +## The deploy loop + +1. Write the file to `/etc/crowdsec/appsec-configs/` (docker: a mounted path in the same + directory; k8s: the log-processor ConfigMap). +2. Add its `name:` to `appsec_configs:` in the acquisition — **by name**. The + `crowdsecurity/appsec-bot-*` glob does not match your namespace. +3. Reload, then `cscli appsec-configs list` to confirm it is loaded. + +```yaml +listen_addr: 127.0.0.1:7422 +appsec_configs: + - crowdsecurity/appsec-bot-* + - mycorp/appsec-bot-challenge-overlay +labels: + type: appsec +source: appsec +``` + +**Order matters.** Configs load in the order listed. A config that re-weights scores must +load *after* `crowdsecurity/appsec-bot-challenge-scoring` and *before* the threshold config, +because `RejectSubmission()` is terminal — it halts every remaining +`on_challenge_submit` rule. + +## Which hook, which helper + +| Hook | When | Key helpers | +|---|---|---| +| `pre_eval` | Before in-band WAF rules, every request | `ExemptFromChallenge` · `GrantChallengeCookie` · `SetChallengeDifficulty` · `MatchKnownBot` · `DropRequest` · `AddRequestScore` | +| `post_eval` | After in-band WAF rules | `SendChallenge` · `SetChallengeDifficulty` · `GrantChallengeCookie` · `ExemptFromChallenge` · `DumpFingerprint` | +| `on_challenge` | Request carrying a valid cookie, before `pre_eval`; `fingerprint` populated | `SendChallenge` · `EvaluateMismatches` · `SetChallengeDifficulty` · `SetRemediation` · `SetReturnCode` · `DropRequest` | +| `on_challenge_submit` | At the `/submit` POST, after crypto validation, before the cookie is issued | `RejectSubmission` · `GrantChallengeCookie` · `LogAccepted` · `EvaluateMismatches` · `DumpFingerprint` · `CancelAlert` | + +Score helpers (`AddRequestScore`, `RequestScore`, `RequestScoreReasons`, +`RequestScoreDetail`, `RequestScoreFor`) are available in all of the above plus `on_match`. + +Two placement rules the engine enforces at startup: + +- **`SendChallenge()` is not available in `pre_eval`** — a challenge may only be issued after + in-band evaluation. Using it there is a fatal config error: + ``` + FATAL … unable to build pre_eval hook : unable to compile apply SendChallenge() : unknown name SendChallenge (1:1) + ``` +- **`on_challenge` and `on_challenge_submit` are in-band only.** Under `outofband:`: + ``` + FATAL … on_challenge hooks are only valid in-band, not under outofband + ``` + +`on_challenge_submit` deliberately exposes no helper that changes the response shape +(`SendChallenge`, `SetRemediation`, `SetReturnCode`, `DropRequest`) — it answers a JSON +handshake the client-side JS is parsing. Escalate from `pre_eval` on the *next* request +instead. + +## Let legitimate traffic through + +Four mechanisms. Pick by what you can prove about the client: + +| You can prove | Use | Scope | +|---|---|---| +| Nothing — it is just this path/host | `ExemptFromChallenge(reason)` | Per request, no cookie | +| A shared secret or header | `GrantChallengeCookie(reason, ttl?)` | Persists via cookie, 307 redirect | +| Published IP ranges or rDNS (a real crawler) | `MatchKnownBot(...)` + a `data:` file | Per request | +| A fixed source IP you control | A native LAPI allowlist | Bypasses AppSec entirely | + +A LAPI allowlist is the bluntest and often the right answer for your own infrastructure — an +allowlisted IP never reaches AppSec at all, is never challenged and never issued a cookie. +See [../../configure/allowlists.md](../../configure/allowlists.md). + +### Scope the challenge to what matters + +Challenging your whole site is rarely what you want. Invert it — exempt everything except the +funnel you care about: + +```yaml +name: mycorp/appsec-bot-challenge-checkout-only +inband: + pre_eval: + - filter: '!(req.URL.Path startsWith "/checkout/")' + apply: + - ExemptFromChallenge("outside-checkout") +``` + +Per host, for a multi-vhost listener: + +```yaml +name: mycorp/appsec-bot-challenge-protected-surface +inband: + pre_eval: + - filter: req.Host != "foobar.com" && req.Host != "api.foobar.com" + apply: + - ExemptFromChallenge("unprotected-host") + - filter: req.Host == "api.foobar.com" && !(req.URL.Path startsWith "/v1/login") + apply: + - ExemptFromChallenge("api-outside-login") +``` + +Proxies may forward `foobar.com:443` rather than the bare host — use `startsWith`, or +`req.Host == "foobar.com" || req.Host endsWith ".foobar.com"` for a whole domain. + +Verify with the exempt metric, which counts by reason: + +``` +| Bot Detection — Exempted | +| Appsec Engine | Reason | Count | +| 127.0.0.1:7422/ | internal-health | 1 | +| 127.0.0.1:7422/ | crawler-files | 3 | +``` + +### An internal probe + +```yaml +name: mycorp/appsec-bot-challenge-overlay +inband: + pre_eval: + - filter: req.Header.Get("X-Internal-Probe") == "s3cr3t" && req.RemoteAddr startsWith "10." + apply: + - GrantChallengeCookie("internal-probe", "24h") +``` + +The response is a `307` carrying the cookie, so the client lands where it was going: + +```json +{ + "action": "challenge", + "http_status": 307, + "user_cookies": ["__crowdsec_challenge=ABme-J7g…; Path=/; Max-Age=86399; HttpOnly; SameSite=Lax"], + "user_headers": { "Location": ["/"], "Cache-Control": ["no-store"] } +} +``` + +A shared-secret header is a bypass token the moment it leaks. Always pair it with a source +constraint as above, and prefer a LAPI allowlist when the source IP is stable. + +Drop the `, "24h"` to use the configured `cookie_ttl`. Use `ExemptFromChallenge` instead if +you want no cookie at all. + +### A crawler of your own + +`MatchKnownBot` checks a JSON datafile of bot definitions: + +``` +(user_agent matches AND at least one path matches) AND (exact IP OR CIDR range OR FCrDNS) +``` + +Newline-delimited JSON, one definition per line, landing under +`/legit_bots/` (usually `/var/lib/crowdsec/data/legit_bots/`): + +```json +{"name":"mybot","user_agent":"mycorp-monitor","paths":["^/health(/|$)","^/status$"],"ranges":["10.42.0.0/16"],"ips":["192.0.2.77"]} +``` + +`name` is required. `user_agent` and `paths` are regexes; omitting them matches anything. At +least one of `ips` / `ranges` / `rdns` is required — a user-agent-only definition is rejected +at load, because a UA is trivially spoofed. `rdns` entries are forward-confirmed reverse DNS +regexes; anchor them (`(^|\.)googlebot\.com$`), or `evilgooglebot.com` matches. Parse, DNS +and unknown-file errors all fail closed — the bot is challenged. + +> **The `data:` block is mandatory.** Dropping the file into `legit_bots/` by hand does +> nothing: only files declared in `data:` are registered in the expr datafile registry, and +> `MatchKnownBot` silently returns false for anything else. Verified — the same config +> matched nothing until the `data:` block was added. + +```yaml +name: mycorp/appsec-bot-challenge-known-bot +inband: + pre_eval: + - filter: MatchKnownBot(req.RemoteAddr, req.UserAgent(), req.URL.Path, "legit_bots/mybot.json") + apply: + - ExemptFromChallenge("mybot") +data: + - source_url: https://example.com/mybot.json + dest_file: legit_bots/mybot.json + type: bots +``` + +Behaviour of the definition above, verified: + +| Request | Result | +|---|---| +| `/status` from `10.42.0.9`, UA `mycorp-monitor/1.0` | exempt — range + UA + path | +| `/status` from `192.0.2.77`, same UA | exempt — exact IP | +| `/status` from `1.2.3.4`, same UA | challenged — UA alone proves nothing | +| `/other` from `10.42.0.9`, same UA | challenged — path not listed | + +FCrDNS needs working reverse DNS resolution from the engine. The DNS cache is global engine +config (`crowdsec_service` → `dns_cache`), not part of the appsec-config. + +## Catch bots the shipped scoring misses + +`EvaluateMismatches()` returns a cached-per-request report over the fingerprint: + +| Call | Returns | +|---|---| +| `.Has("cdp")` | Did this signal fire | +| `.Count()` | How many fired | +| `.High()` / `.Medium()` / `.Low()` | Count by severity | +| `.Reasons()` | The signal names | + +### What a signal is worth + +**Points live in the hub config, not the engine.** `crowdsecurity/appsec-bot-challenge-scoring` +is the source of truth for what each signal contributes, and it is a normal hub item — it gets +revised, and you can override it. Always read the installed file rather than trusting a table: + +```bash +sudo grep -B2 AddRequestScore /etc/crowdsec/appsec-configs/appsec-bot-challenge-scoring.yaml +``` + +As shipped in `appsec-bot-challenge-scoring` **0.2**: + +| Points | Signals | Rationale in the config | +|---|---|---| +| **100** | `cdp`, `webdriver`, `webdriver_writable`, `selenium`, `playwright`, `webdriver_iframe`, `webdriver_worker`, `bot_user_agent` | Declared automation. One is enough to clear every shipped threshold. | +| **50** | `headless_screen_resolution`, `missing_chrome_object`, `impossible_memory`, `inconsistent_etsl`, `mismatch_webgl_worker`, `mismatch_platform_iframe`, `mismatch_platform_worker` | Headless environments and cross-context inconsistencies — hard to fake away, thin tail of legitimate clients. | +| **30** | `platform_mismatch`, `gpu_mismatch`, `high_cpu_count` | Suspicious, but reachable by odd-but-real setups (VMs, remote desktops). | +| **15** | `utc_timezone`, `ua_mobile`, `accept_language` | Common enough among real visitors that acting on one alone is a false positive. | +| **5** | `swiftshader_renderer`, `mismatch_languages`, `timezone_country` | Only meaningful in aggregate. | + +### Severity is a different axis + +`.High()` / `.Medium()` / `.Low()` do **not** read those points. Severity is a fixed label the +engine attaches to each signal, and the two do not line up: every signal from 30 points upward +is `high`, only the 15-point ones are `medium`, only the 5-point ones are `low`. + +So `EvaluateMismatches().High() >= 1` fires on a lone `gpu_mismatch` — a 30-point signal, +nowhere near any shipped threshold. If you mean "strong evidence", filter on `RequestScore()` +or name the signals with `.Has(...)`; reach for `.High()` only when you genuinely want any +high-severity signal regardless of weight. + +Re-weight a signal for your traffic — load this **between** the scoring config and the +threshold config: + +```yaml +name: mycorp/appsec-bot-challenge-reweight +inband: + on_challenge_submit: + # A UTC-only audience is unusual for us; treat it as stronger evidence. + - filter: EvaluateMismatches().Has("utc_timezone") + apply: + - AddRequestScore(45, "utc_timezone_mycorp") + # Our app is mobile-first, so a mobile UA mismatch is expected noise. + - filter: EvaluateMismatches().Has("ua_mobile") + apply: + - AddRequestScore(-15, "ua_mobile_expected") +``` + +Scores may be negative. Add application-specific evidence the fingerprint cannot see: + +```yaml + - filter: req.URL.Path startsWith "/checkout" && req.Header.Get("Referer") == "" + apply: + - AddRequestScore(30, "checkout_no_referer") +``` + +Reject on signal shape rather than score: + +```yaml + - filter: EvaluateMismatches().High() >= 1 + apply: + - 'RejectSubmission("high-severity mismatch")' +``` + +`RejectSubmission(reason, verbosity)` takes an optional `"minimal"` / `"info"` (default) / +`"verbose"` controlling how much fingerprint detail is logged. + +Escalate instead of rejecting — make a suspicious client work harder on its next challenge: + +```yaml + on_challenge: + - filter: EvaluateMismatches().High() >= 1 + apply: + - SetChallengeDifficulty("high") + - SendChallenge() +``` + +Or hard-drop a client that carries a valid cookie but now shows automation signals: + +```yaml + on_challenge: + - filter: fingerprint.HasAutomationSignal() + apply: + - 'DropRequest("automation signal on cookie-bearing client")' +``` + +The `fingerprint` object also offers `IsBot()`, `HasBotSignal()`, `BotSignalCount()`, +`BotSignals()`, `HasHeadlessSignal()`, `HasMismatchSignal()`, `Platform()`, `Timezone()`, +`Language()`, `IsMobile()`, `CPUCount()`, `Memory()`. Full shape: + + +## Custom scenarios on challenge events + +A persistent bot is already covered without any custom scenario. A rejected submission is +still a submission, so it emits `submitted` as well as `rejected`, and +`crowdsecurity/appsec-bot-challenge-too-many-submissions` fills on it — +verified: eight consecutive CDP-browser rejections produced a ban from that scenario, not +just alerts. A real browser solves once, takes its cookie and stops submitting, so it does +not trip the same bucket. + +What the shipped scenarios do *not* give you is a rejection-specific reaction: no scenario +filters on `challenge_event` being `rejected`, `failed` or `solved`. Write one when you want +to ban faster than raw submission volume allows, react to a particular signal, or treat a +single rejection as terminal for a sensitive route. + +Both field paths work; the shipped scenarios use `evt.Meta`, upstream docs teach +`evt.Parsed`. `evt.Meta` fields are the ones the hub parser sets, so prefer them for +consistency with the shipped content: + +```yaml +# /etc/crowdsec/scenarios/mycorp-appsec-bot-rejected.yaml +type: leaky +name: mycorp/appsec-bot-rejected +description: "Ban clients repeatedly rejected by the bot challenge" +filter: | + evt.Meta.log_type == 'appsec-challenge' && + evt.Meta.challenge_event == 'rejected' +groupby: evt.Meta.source_ip +capacity: 3 +leakspeed: 10m +blackhole: 5m +labels: + service: http + confidence: 3 + spoofable: 0 + behavior: "http:bot" + label: "Repeatedly failed the CrowdSec bot challenge" + remediation: true +``` + +Fields the parser exposes on the event: `source_ip`, `target_host`, `target_uri`, +`request_uuid`, `challenge_event`, `challenge_difficulty`, `challenge_fail_reason`, `fsid`, +`fingerprint_bot`, `http_user_agent`, `request_score`, `request_score_reasons`, `os`. + +`request_score_reasons` is a flat string like `"cdp=100,utc_timezone=15"`, so match on it +with `contains`: + +```yaml +filter: | + evt.Meta.log_type == 'appsec-challenge' && + evt.Meta.challenge_event == 'rejected' && + evt.Meta.request_score_reasons contains "cdp=" +``` + +`remediation: true` is what turns the alert into a decision through your profiles — +[../../configure/profiles.md](../../configure/profiles.md). diff --git a/skills/crowdsec/references/appsec/bot-detection/deploy.md b/skills/crowdsec/references/appsec/bot-detection/deploy.md new file mode 100644 index 0000000..7e7758b --- /dev/null +++ b/skills/crowdsec/references/appsec/bot-detection/deploy.md @@ -0,0 +1,262 @@ +--- +verified: + - date: 2026-08-31 + version: "1.8.0" + env: systemd + notes: "full loop: collection install, glob acquisition, nginx bouncer 1.2.2 at default config, challenge served, CDP browser rejected, alert + metrics, too-many-submissions ban after repeated rejections; ALWAYS_SEND_TO_APPSEC semantics re-tested" + - date: 2026-08-31 + version: "1.8.0-rc2" + env: docker + notes: "install + acquisition + engine-level smoke test; no bouncer wired" +--- + +# Deploy bot detection + +Canonical docs: · +[what's included](https://docs.crowdsec.net/docs/next/appsec/bot_detection/whats_included) + +Prerequisite: a working AppSec listener — [../deploy.md](../deploy.md). This doc only adds +bot detection on top. + +## 1 — Pick exactly one bundle + +| Collection | Loads threshold config | Rejects at score | +|---|---|---| +| `crowdsecurity/appsec-bot-challenge` | `…-scoring-balanced` | ≥ 75 | +| `crowdsecurity/appsec-bot-challenge-strict` | `…-scoring-strict` | ≥ 45 | +| `crowdsecurity/appsec-bot-challenge-permissive` | `…-scoring-permissive` | ≥ 100 | + +Start with the balanced bundle. Each also pulls three building blocks you can install on +their own if you are assembling a custom stack: `…-scoring` (the scoring engine, never +rejects), `…-good-bots` (verified-crawler exclusions), `…-exclude-paths` (path exclusions). + +> **Install one bundle, not several.** The `crowdsecurity/appsec-bot-*` glob below loads +> *every* threshold config you have installed, and their rejection rules stack — the +> strictest wins, silently overriding your choice. Verified: installing `-strict` alongside +> the default leaves both `appsec-bot-challenge-scoring-balanced.yaml` and +> `…-strict.yaml` loaded, so rejection happens at 45, not 75. Check with +> `cscli appsec-configs list | grep scoring` — you should see the engine plus **one** +> threshold config. + +```bash +sudo cscli collections install crowdsecurity/appsec-bot-challenge +``` + +| Env | Prefix | +|---|---| +| systemd | `sudo cscli …` | +| docker | `docker exec cscli …` | +| k8s | `kubectl exec -n -- cscli …` | + +Docker: prefer baking it in with `-e COLLECTIONS="crowdsecurity/appsec-bot-challenge"` so it +survives a container replace. + +## 2 — Acquisition + +`/etc/crowdsec/acquis.d/appsec.yaml`: + +```yaml +listen_addr: 127.0.0.1:7422 +appsec_configs: + - crowdsecurity/appsec-default # your existing WAF config, if any + - crowdsecurity/appsec-bot-* +labels: + type: appsec +source: appsec +``` + +Glob patterns in `appsec_configs` are themselves version-gated (see SKILL.md § Step 1.6). They expand against **installed** +appsec-configs only, and a pattern matching nothing is fatal at startup: + +``` +FATAL crowdsec init: while loading acquisition config: /etc/crowdsec/acquis.d/appsec.yaml: datasource of type appsec: unable to resolve appsec_config "crowdsecurity/appsec-bot-*": no installed appsec-config matches pattern "crowdsecurity/appsec-bot-*" +``` + +So install the collection **before** writing the glob. Custom configs of your own are not +matched by the `crowdsecurity/` glob and must be listed by name — see +[./customize.md](./customize.md). + +Docker: mount the acquisition dir *and* a data volume (`/var/lib/crowdsec/data`), which the +entrypoint refuses to start without. Kubernetes: the acquisition goes in the log-processor +ConfigMap. + +Reload, then confirm: + +```bash +sudo systemctl reload crowdsec +sudo cscli appsec-configs list +sudo grep "WAF challenge runtime initialized" /var/log/crowdsec.log +``` + +## 3 — Wire the bouncer + +Setting `APPSEC_URL` is not enough. Bot detection needs the bouncer to send **every** request +to AppSec, and to treat four internal paths as AppSec's, not the origin's. + +### nginx / OpenResty + +`/etc/crowdsec/bouncers/crowdsec-nginx-bouncer.conf`: + +```ini +APPSEC_URL=http://127.0.0.1:7422 +``` + +That is all bot detection needs — the bouncer consults AppSec for every IP that has no +decision, so clean visitors get challenged out of the box. + +**Leave `ALWAYS_SEND_TO_APPSEC` at its default (`false`).** It only governs IPs that are +*already banned*: with the default they get the ban page and AppSec is never consulted, which +is what you want. Setting it to `true` sends banned IPs to AppSec as well, and since the +bot-detection configs challenge everything, a `ban` decision is effectively downgraded to a +challenge. Verified on 1.2.2 — a banned IP returned the 403 ban page with the default and the +challenge page with `true`. + +Then `sudo nginx -t && sudo systemctl restart nginx`. Prefer `restart` over `reload` after +editing the bouncer config: the lua module reads it at init, and a graceful reload can keep +serving from old workers long enough to look like the change had no effect. + +### What has to survive between client and AppSec + +A supported bouncer handles the challenge protocol for you — picking one from +[./overview.md](./overview.md) § Bouncer support is the whole job. What you do own is +everything *else* in the request path: a CDN, reverse proxy or WAF in front of the bouncer can +silently break the challenge. Three things must reach their destination intact: + +- **`/crowdsec-internal/challenge/*`** (the JS assets and the `POST …/submit`) — these are + served by AppSec, not your origin. A proxy that routes, caches, or rewrites them breaks the + loop; symptoms in [./troubleshoot.md](./troubleshoot.md) § 3. +- **The `__crowdsec_challenge` cookie**, in both directions. Anything stripping `Set-Cookie` + or `Cookie` produces an endless challenge loop (§ 4). +- **The real client IP**, on every request including the assets and the submit — not just the + first page. See [../../configure/bouncers/web-servers.md](../../configure/bouncers/web-servers.md) + for per-bouncer real-IP configuration. + +Writing a bouncer that speaks the challenge protocol is out of scope here — the wire format, +status codes and cookie handling are specified in +. + +## 4 — Smoke test + +### a. Engine level — works with no bouncer at all + +Useful anywhere, and the only option if your bouncer has no bot-detection support yet. + +```bash +KEY=$(sudo cscli bouncers add smoketest -o raw) +q(){ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:7422/ \ + -H "x-crowdsec-appsec-api-key: $KEY" \ + -H "x-crowdsec-appsec-ip: $2" \ + -H "x-crowdsec-appsec-host: example.com" \ + -H "x-crowdsec-appsec-uri: $1" \ + -H "x-crowdsec-appsec-verb: GET" \ + -H "x-crowdsec-appsec-user-agent: $3"; } + +q / 1.2.3.4 "Mozilla/5.0" # 403 — challenged +q /robots.txt 1.2.3.4 "Mozilla/5.0" # 200 — crawler-files exclusion +q /style.css 1.2.3.4 "Mozilla/5.0" # 200 — static exclusion +q /api/v1/x 1.2.3.4 "Mozilla/5.0" # 200 — api exclusion +q / 1.2.3.4 "Googlebot/2.1" # 403 — spoofed UA, no IP proof +q / 66.249.66.1 "Googlebot/2.1" # 200 — real Googlebot, FCrDNS verified +``` + +The last two are the useful pair: a bot claiming to be Googlebot from an arbitrary IP is +still challenged, while the real crawler is waved through. `403` here means "challenge +issued", not "banned" — see the `http_status` note in [./overview.md](./overview.md). + +To see the envelope itself: + +```bash +curl -s http://127.0.0.1:7422/ -H "x-crowdsec-appsec-api-key: $KEY" \ + -H "x-crowdsec-appsec-ip: 1.2.3.4" -H "x-crowdsec-appsec-host: example.com" \ + -H "x-crowdsec-appsec-uri: /" -H "x-crowdsec-appsec-verb: GET" \ + -H "x-crowdsec-appsec-user-agent: Mozilla/5.0" \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); d["user_body_content"]=d["user_body_content"][:60]; print(json.dumps(d,indent=2))' +``` + +```json +{ + "action": "challenge", + "http_status": 200, + "user_body_content": "\n\n \n CrowdSec", + "user_headers": { + "Cache-Control": ["no-cache, no-store"], + "Content-Security-Policy": ["default-src 'self'; script-src 'self' 'unsafe-inline'; …"], + "Content-Type": ["text/html"] + } +} +``` + +No `user_cookies` on the initial challenge — the cookie only appears on a solved submission +or a `GrantChallengeCookie()` grant. + +### b. Through the bouncer + +```bash +curl -s -o /tmp/c.html -w "%{http_code} %{size_download}\n" http://your-site/ +grep -c "CrowdSec Challenge" /tmp/c.html +``` + +Expect `200` with a body of a few hundred KB containing `CrowdSec Challenge` — the engine +returned 403 and the bouncer rendered it as a 200 page. + +### c. Prove a bot gets caught + +Point any CDP-driven browser at the site — Puppeteer, Playwright-Chromium, or +`chrome --remote-debugging-port`. The `cdp` signal alone scores 100, over every threshold. + +```js +// npm i puppeteer && node cdp-check.js +const puppeteer = require("puppeteer"); +(async () => { + const b = await puppeteer.launch({ headless: false }); + const p = await b.newPage(); + await p.goto("http://your-site/", { waitUntil: "networkidle0" }); + await b.close(); +})(); +``` + +Expected — the browser solves the PoW, submits, and is rejected on its fingerprint: + +``` +level=info msg="on_challenge_submit rejected" automation=true cpu_count=16 fsid=FS1_0000100… is_bot=true language=en-US reason="request score 100" signals="[cdp]" source=203.0.113.7 timezone=Europe/Paris ua="Mozilla/5.0 (X11; Linux x86_64) … Chrome/148.0.0.0 Safari/537.36" url="http://your-site/" +level=info msg="WAF bot-detection: 203.0.113.7 rejected by crowdsecurity/rejected-browser-submission (request score 100)" +``` + +```bash +sudo cscli alerts list --kind bot-detection +sudo cscli alerts inspect <id> -d +``` + +``` +| bot_detected | true | +| challenge_event | rejected | +| fail_reason | request score 100 | +| request_score | 100 | +| score_reasons | cdp=100 | +| target_uri | / | +``` + +The alert shows `Remediation: false` — one rejection records the catch without banning. Keep +the bot running and the shipped scenarios ban it; verified here after eight consecutive +rejections: + +| Scenario | Fires on | +|---|---| +| `crowdsecurity/appsec-bot-challenge-too-many-requests` | Served the challenge 10×/20s without ever submitting. Cancelled as soon as a submission arrives, so a real browser never trips it. | +| `crowdsecurity/appsec-bot-challenge-too-many-submissions` | 5 submissions/20s. Rejected submissions count too, so this is what bans a bot that keeps failing the fingerprint check. A real browser solves once, takes its cookie and stops submitting. | + +### d. Metrics + +```bash +sudo cscli metrics show bot-detection +``` + +``` +| Bot Detection | Requested | Submitted | Solved | Granted | Exempt | Protocol Failures | Submissions Rejected | Cookies Invalid | +| 127.0.0.1:7422/ | 7 | 1 | - | - | 7 | - | 1 | - | +``` + +Detail in [./troubleshoot.md](./troubleshoot.md) § 7. + +Next: [./configure.md](./configure.md) for multi-instance and cookie tuning, +[./customize.md](./customize.md) to change who gets challenged. diff --git a/skills/crowdsec/references/appsec/bot-detection/overview.md b/skills/crowdsec/references/appsec/bot-detection/overview.md new file mode 100644 index 0000000..eb13446 --- /dev/null +++ b/skills/crowdsec/references/appsec/bot-detection/overview.md @@ -0,0 +1,149 @@ +--- +verified: + - date: 2026-08-31 + version: "1.8.0" + env: systemd + notes: "challenge envelope, event vocabulary, exclusion behaviour; conceptual doc" + - date: 2026-08-31 + version: "1.8.0-rc2" + env: docker + notes: "challenge envelope + exclusions only" +--- + +# Bot detection (AppSec challenge mode) — what it is + +Canonical docs: <https://docs.crowdsec.net/docs/next/appsec/bot_detection/intro> · +[how it works](https://docs.crowdsec.net/docs/next/appsec/bot_detection/how_it_works) · +[challenge protocol](https://docs.crowdsec.net/docs/next/appsec/bot_detection/challenge_protocol) + +Version and prerequisites: SKILL.md § Step 1.6 — Feature compatibility. + +**Marked alpha upstream** — config keys, expr helpers and the shipped hub rules may change +between releases. Say so before someone builds a rollout on it. + +## Not a captcha + +| | Captcha | Bot detection (challenge) | +|---|---|---| +| Who renders it | The bouncer, from its own template | The **engine**, served through the bouncer | +| Third-party provider | hCaptcha / reCAPTCHA / Turnstile keys required | None | +| User interaction | Yes — solve a puzzle | None — the page self-solves | +| What it proves | A human clicked | The client is a real browser (runs JS, has a plausible device fingerprint) | + +The engine serves a small HTML page that runs a **proof-of-work** in a worker and collects a +**device fingerprint** (the open-source `fpscanner` library). The browser POSTs the result +back; hooks score the fingerprint; a sealed `__crowdsec_challenge` cookie is issued on +success. Headless Chromium, Selenium, Playwright and CDP-driven browsers solve the PoW fine +— they get caught by the *fingerprint*, not the puzzle. + +`challenge` is a fourth AppSec action next to `allow` / `ban` / `captcha`. Restrictiveness +ordering used by remediation components: `allow < unknown < captcha < challenge < ban`. + +It is **not** a LAPI decision type — it cannot be set from `profiles.yaml`, only from an +appsec-config. See [../../configure/profiles.md](../../configure/profiles.md) § Decision types. + +## Request flow + +``` + ┌─────────── first visit ───────────┐ +client ──▶ bouncer ──▶ AppSec :7422 ──▶ in-band WAF rules + └──▶ post_eval: SendChallenge() + │ + 403 to the bouncer + JSON envelope ◀─┘ + {action:"challenge", http_status:200, user_body_content:"<html>…", + user_headers:{…}} ← no cookie yet + │ + bouncer serves the body to the client with http_status + │ + browser runs PoW + fingerprint, POSTs /crowdsec-internal/challenge/submit + │ + AppSec validates crypto/PoW ──▶ on_challenge_submit hooks score it + │ + {"status":"ok"} + Set-Cookie __crowdsec_challenge → allowed + {"status":"failed"} crypto/PoW invalid + {"status":"rejected"} a hook called RejectSubmission() → alert + │ + ┌─── later visits carrying a valid cookie ───┐ + AppSec runs on_challenge hooks (before pre_eval), fingerprint available +``` + +The outer HTTP status to the *bouncer* is always **403** for any non-allow action. The status +the *client* sees is `http_status` inside the envelope — `200` for a challenge page, `307` +for a `GrantChallengeCookie()` redirect. Confusing the two is the most common misreading of +a `curl` against `:7422`. + +## Terminology + +Extends [../overview.md](../overview.md) § Terminology. + +| Term | Meaning | +|---|---| +| Challenge | The PoW + fingerprint page the engine serves | +| Fingerprint / FSID | The device fingerprint and its stable id, visible in alerts as `fingerprint_id` | +| Difficulty | PoW cost: `disabled` / `low` / `medium` (default) / `high` / `impossible` | +| Exemption | Request skipped the challenge entirely (`ExemptFromChallenge`) — per request, no cookie | +| Granted cookie | Challenge waived *and* a cookie issued (`GrantChallengeCookie`) — persists | +| Master secret / epoch | Root key all per-epoch signing keys derive from; must be shared across instances | +| Score | Points accumulated from fingerprint mismatch signals; a threshold config rejects above it | + +## Event vocabulary + +Challenge events reach the pipeline as a **separate source** from WAF rule matches, so +challenge scenarios never collide with WAF scenarios: + +| | WAF rule match | Challenge | +|---|---|---| +| `evt.Parsed.source` | `crowdsec-appsec` | `crowdsec-appsec-challenge` | +| Parser | `crowdsecurity/appsec-logs` | `crowdsecurity/appsec-bot-detection-logs` | +| `evt.Meta.log_type` | `appsec` | `appsec-challenge` | + +`evt.Meta.challenge_event` / `evt.Parsed.challenge_event` takes five values: + +| Value | Meaning | +|---|---| +| `requested` | Challenge page served | +| `submitted` | A submission arrived | +| `failed` | Submission failed crypto / PoW validation | +| `rejected` | Submission decoded fine but a hook called `RejectSubmission()` — **this is the "caught a bot" signal** | +| `solved` | Validated, cookie issued | + +A rejection raises an alert with kind **`bot-detection`** and reason +`crowdsecurity/rejected-browser-submission`, carrying `Remediation: false` — **the alert +alone never bans**. Only the two shipped leaky scenarios turn repeated abuse into a decision. + +## Prerequisites + +1. A working AppSec setup — [../deploy.md](../deploy.md) first. +2. A **bouncer that supports bot detection** (see below). Enabling it behind one that does + not will refuse clients, not challenge them. +3. A host that can run the challenge runtime: obfuscating the challenge JS needs a + WebAssembly runtime in compiler mode — `arm64`, or `amd64` **with SSE4.1** — and the + engine must be allowed to map executable memory. W^X hardening, a seccomp profile or an + SELinux policy can deny it, and the AppSec datasource then fails to load and CrowdSec does + not start. See [./troubleshoot.md](./troubleshoot.md) § 1. +4. Clients that run JavaScript and accept cookies. API clients, feed readers and CLI tools + cannot — exclude them ([./customize.md](./customize.md) § Let legitimate traffic through). + +Startup confirmation, all environments: + +``` +level=info msg="WAF challenge runtime initialized" cookie_ttl=12h0m0s crypto_pool_size=1 max_cookie_len=4096 module=challenge pow_difficulty=20 rotation_interval=5m0s +``` + +## Bouncer support + +| Bouncer | Bot detection | Verified here | +|---|---|---| +| nginx (`crowdsec-nginx-bouncer`) | ✅ | ✅ 1.2.2 | +| OpenResty | ✅ | — | +| HAProxy **SPOA** (not the Lua bouncer) | ✅ | — | +| Traefik | ✅ | — | +| Envoy | ✅ | — | +| apache, cloudflare, aws-waf, fastly, firewall, blocklist-mirror, php/wordpress | ❌ | — | + +**Upstream documents no minimum version for any of them.** The only version this skill has +observed working is `crowdsec-nginx-bouncer` **1.2.2** against engine **1.8.0**. For anything +else, check the bouncer ships `challenge.lua` (lua-based ones) or carries the "Bot Detection" +badge on its docs page, and test before rolling out. + +Next: [./deploy.md](./deploy.md) to turn it on. diff --git a/skills/crowdsec/references/appsec/bot-detection/troubleshoot.md b/skills/crowdsec/references/appsec/bot-detection/troubleshoot.md new file mode 100644 index 0000000..1110405 --- /dev/null +++ b/skills/crowdsec/references/appsec/bot-detection/troubleshoot.md @@ -0,0 +1,268 @@ +--- +verified: + - date: 2026-08-31 + version: "1.8.0" + env: systemd + notes: "metrics tables, config error strings, ALWAYS_SEND_TO_APPSEC semantics (banned-IP only), glob-resolution fatal, master_secret warning, custom scenario → ban chain" +--- + +# Bot detection — troubleshooting + +Run [`scripts/diagnose.sh`](../../../scripts/diagnose.sh) first for the general picture, then +come here. For AppSec problems that are not challenge-specific, use +[../troubleshoot.md](../troubleshoot.md). + +Quick orientation — where in the funnel does it break? + +```bash +sudo cscli metrics show bot-detection +``` + +| Column stuck at zero | Look at | +|---|---| +| `Requested` | § 2 — no challenge is being issued at all | +| `Submitted` (but `Requested` climbing) | § 3 — the client never posts back | +| `Solved` (but `Submitted` climbing) | § 4 / § 6 — everything is being rejected | +| `Exempt` unexpectedly high | § 5 — an exclusion is too broad | + +## 1 — CrowdSec won't start after enabling it + +``` +FATAL … failed to create wasm runtime in compiler mode +FATAL … wasm compiler mode unavailable +``` + +The challenge JS is obfuscated through a WebAssembly runtime in compiler mode. It needs +`arm64`, or `amd64` **with SSE4.1**, and permission to map executable memory. Check in order: + +```bash +grep -o sse4_1 /proc/cpuinfo | head -1 # must print sse4_1 on amd64 +sudo journalctl -u crowdsec -n 50 --no-pager | grep -i wasm +sudo ausearch -m avc -ts recent 2>/dev/null | tail # SELinux denials +``` + +Common causes: an old/emulated CPU, a hardened kernel with W^X enforcement, a restrictive +seccomp profile (docker `--security-opt seccomp=…`), or an SELinux policy denying +`execmem`. There is no software fallback — fix the host or run bot detection elsewhere. + +Different fatal, same moment: + +``` +FATAL … unable to resolve appsec_config "crowdsecurity/appsec-bot-*": no installed appsec-config matches pattern "crowdsecurity/appsec-bot-*" +``` + +The glob is in the acquisition but the collection was never installed. Install it, then +reload — [./deploy.md](./deploy.md) § 1. + +Hook placement errors, both fatal at startup: + +| Error | Fix | +|---|---| +| `unable to compile apply SendChallenge() : unknown name SendChallenge` | `SendChallenge()` is not available in `pre_eval`. Move it to `post_eval` or `on_challenge`. | +| `on_challenge hooks are only valid in-band, not under outofband` | Move the hook under `inband:`. | + +## 2 — No challenge is ever served + +`Requested` stays at zero and pages load normally. + +1. **Is `APPSEC_URL` set, and did the change actually take effect?** + ```bash + grep -E "^APPSEC_URL" /etc/crowdsec/bouncers/crowdsec-nginx-bouncer.conf + ``` + Empty means the WAF is off entirely. If it is set and nothing changed, `systemctl restart + nginx` rather than `reload` — the lua module reads the config at init and a graceful + reload can keep old workers serving, which looks exactly like the setting being ignored. + (`ALWAYS_SEND_TO_APPSEC` is **not** the answer here; it only affects already-banned IPs. + See [./deploy.md](./deploy.md) § 3.) +2. **Is the config loaded?** `cscli appsec-configs list` should show the + `appsec-bot-challenge-*` entries. If not, the acquisition does not reference them. +3. **Test the engine directly**, bypassing the bouncer entirely — + [./deploy.md](./deploy.md) § 4a. If curl gets a `challenge` envelope but the browser does + not, the problem is the bouncer, not the engine. +4. **Is something exempting everything?** Check the exempt-by-reason table (§ 5). + +## 3 — Challenge served, but nothing ever submits + +`Requested` climbs, `Submitted` stays at zero. + +The bouncer is serving the page but not forwarding the challenge's own traffic back to +AppSec. All four internal paths must reach AppSec unmodified, never the origin: + +``` +GET /crowdsec-internal/challenge/challenge.js +GET /crowdsec-internal/challenge/fpscanner.js +GET /crowdsec-internal/challenge/pow-worker.js +POST /crowdsec-internal/challenge/submit +``` + +```bash +curl -s -o /dev/null -w "%{http_code}\n" http://your-site/crowdsec-internal/challenge/pow-worker.js +``` + +A `404` from your origin means the bouncer is not intercepting them. Check the browser +console too — a Content-Security-Policy on your side that blocks `blob:` workers or inline +script stops the PoW from ever running; the challenge ships its own CSP, and a stricter one +added by the origin or a proxy overrides it. + +Also plausible: the client genuinely cannot solve it. API clients, feed readers, curl and +mobile apps do not run JS. Exempt them by path — +[./customize.md](./customize.md) § Let legitimate traffic through. + +## 4 — Endless challenge loop + +The browser solves the challenge, gets a cookie, and is challenged again on the next request. +The cookie is not surviving the round trip. + +| Cause | Check | +|---|---| +| Several AppSec instances with no shared `master_secret` | The startup warning below; fix per [./configure.md](./configure.md) § Multi-instance | +| Engine restarted with an ephemeral secret | Same warning — all outstanding cookies died with the process | +| A proxy stripping `Set-Cookie` or `Cookie` | Compare what the bouncer emits with what the browser stores | +| Bouncer collapsing multiple `user_cookies` into one header | Each entry must be its own `Set-Cookie` | +| Clock skew beyond `(max_live_epochs + 1) × key_rotation_interval` | `timedatectl` on every node | + +``` +level=warning msg="no master secret configured for the WAF challenge runtime; generated an ephemeral random secret. Distributed (multi-WAF) deployments MUST configure a shared master_secret in the appsec config; single-instance deployments will see outstanding challenge cookies invalidated on restart." module=challenge +``` + +`Cookies Invalid` in the metrics table counts exactly this — clients presenting a cookie the +runtime will not open. + +## 5 — Legitimate clients are being challenged or blocked + +Read the exempt table first; it tells you which exclusions are firing and which are not: + +```bash +sudo cscli metrics show bot-detection +``` + +``` +| Bot Detection — Exempted | +| Appsec Engine | Reason | Count | +| 127.0.0.1:7422/ | api | 1 | +| 127.0.0.1:7422/ | crawler-files | 2 | +| 127.0.0.1:7422/ | googlebot | 1 | +``` + +**A real crawler is being challenged.** `MatchKnownBot` requires an identity proof, not just +a user-agent — exact IP, CIDR range, or forward-confirmed reverse DNS. So: + +- Confirm rDNS actually resolves from the engine host: `dig -x <crawler-ip> +short`, then + forward-resolve the name back. If either leg fails, the match fails closed. +- Check the engine's global `dns_cache` config (`crowdsec_service` → `dns_cache`) is not + disabled. +- A definition with only `user_agent` is rejected at load time by design. +- If it is **your own** datafile: it must be declared in a `data:` block. A file dropped into + `legit_bots/` by hand is never registered and `MatchKnownBot` silently returns false — + [./customize.md](./customize.md) § A crawler of your own. + +**A human is being challenged repeatedly** — that is § 4, not this section. + +**Everything is blocked, not challenged.** The bouncer does not support bot detection and is +falling back to `ban`. Check it against [./overview.md](./overview.md) § Bouncer support. + +## 6 — Too many, or too few, rejections + +```bash +sudo cscli appsec-configs list | grep scoring +``` + +You should see the scoring engine plus **exactly one** threshold config. Two threshold +configs means two bundles are installed and the strictest one silently wins — +[./deploy.md](./deploy.md) § 1. + +The reject table doubles as a score histogram: each distinct `RejectSubmission` reason string +appears with its count, and the shipped threshold configs put the score in the reason +(`request score 115`). If real users cluster just above your threshold, move to the +permissive bundle or re-weight the offending signal — +[./customize.md](./customize.md) § Catch bots the shipped scoring misses. + +Remember what a single rejection does: it raises a `bot-detection` alert with +`Remediation: false`, which does not ban by itself. Repeated rejections do ban, through +`crowdsecurity/appsec-bot-challenge-too-many-submissions` — every rejected submission is +still a `submitted` event, so the bucket fills (capacity 5, leakspeed 20s). If a bot is +being rejected but never banned, it is retrying too slowly to overflow that bucket; add a +scenario on `challenge_event == 'rejected'` to react sooner +([./customize.md](./customize.md) § Custom scenarios on challenge events). + +## 7 — Reading the metrics + +`cscli metrics show bot-detection` renders two sections (`appsec-challenge` and +`appsec-challenge-infra`, listed by `cscli metrics list`): + +``` +| Bot Detection | Requested | Submitted | Solved | Granted | Exempt | Protocol Failures | Submissions Rejected | Cookies Invalid | +| 127.0.0.1:7422/ | 7 | 1 | - | - | 7 | - | 1 | - | +``` + +| Column | Meaning | +|---|---| +| `Requested` / `Submitted` | Pages served / submissions received | +| `Solved` | Passed validation and got a cookie | +| `Granted` | Cookie issued by `GrantChallengeCookie()`, no challenge solved | +| `Exempt` | Skipped by `ExemptFromChallenge()` — broken out by reason below | +| `Protocol Failures` | Crypto or PoW validation failed — malformed or forged submissions | +| `Submissions Rejected` | Validation passed, a hook called `RejectSubmission()` — real detections | +| `Cookies Invalid` | Incoming cookie could not be opened (see § 4) | + +Infrastructure counters (signing key regenerated/evicted, re-obfuscation, dynamic module +evicted) are housekeeping and process-global. They should tick slowly and steadily; a +regeneration rate far above `key_rotation_interval` means something is restarting the runtime. + +`cscli metrics show appsec-engine` also gains `Ch. Requested` / `Ch. Accepted` / +`Ch. Rejected` columns. + +Prometheus: `cs_appsec_challenge_requested_total`, `…_submitted_total`, +`…_accepted_total` (label `kind` = `solved` | `granted`), `…_rejected_total` (`kind` = +`protocol` | `submission` | `cookie`), `cs_appsec_challenge_exempt_total` (label `reason`), +and `cs_appsec_fingerprint_mismatch_total` (labels `reason`, `severity`). + +## 8 — Turning up the logs + +Raise verbosity for the challenge runtime alone, without making the rest of CrowdSec noisy — +add to your overlay appsec-config: + +```yaml +challenge: + log_level: debug +``` + +```bash +sudo tail -F /var/log/crowdsec.log | grep -E "challenge submission|on_challenge_submit|module=challenge" +``` + +Line shapes to expect: + +``` +level=info msg="challenge submission accepted" source=198.51.100.42 fsid=FS1_… is_bot=false allowlisted=false +level=info msg="on_challenge_submit rejected" automation=true cpu_count=16 fsid=FS1_… is_bot=true language=en-US reason="request score 100" signals="[cdp]" source=203.0.113.7 timezone=Europe/Paris ua="Mozilla/5.0 …" url="http://your-site/" +``` + +`RejectSubmission("reason", "verbose")` adds `nonce`, `fp_time`, `memory`, `platform`, +`request_uuid` and the full signal list to that line. + +## 9 — Capturing a fingerprint to inspect + +```yaml + on_challenge_submit: + - filter: "fingerprint.IsBot()" + apply: + - 'DumpFingerprint("suspected-automation")' +``` + +Writes JSONL to `<datadir>/fingerprint_dumps/crowdsec_fp_dump_suspected-automation.jsonl` +with the fingerprint plus client IP, UA, host, URI and timestamp. + +> Load this config **before** the threshold config. `RejectSubmission()` is terminal — it +> halts every remaining `on_challenge_submit` rule, so a dump rule loaded after it never runs +> on the requests you most want to inspect. + +## 10 — Console shows the alert but not the detail + +The context (`score_reasons`, `fingerprint_id`, `request_score`) rides on the alert but is +hidden by default. Enable the context column or Comfort view in the Console alerts settings. +Locally the same data is always available: + +```bash +sudo cscli alerts inspect <id> -d +``` diff --git a/skills/crowdsec/references/appsec/configure.md b/skills/crowdsec/references/appsec/configure.md index 91d92bb..975a376 100644 --- a/skills/crowdsec/references/appsec/configure.md +++ b/skills/crowdsec/references/appsec/configure.md @@ -97,7 +97,7 @@ Disabling a rule the appsec-config still references will trip `unable to load in ## Hooks -Hooks let you mutate the request, add context, or short-circuit evaluation. They fire at three phases: +Hooks let you mutate the request, add context, or short-circuit evaluation. They fire at these phases: | Phase | When | Typical use | |---|---|---| @@ -105,6 +105,18 @@ Hooks let you mutate the request, add context, or short-circuit evaluation. They | `pre_eval` | Before any rule runs against a request. | Inject custom variables from request headers, classify the request, decide if rules should evaluate at all. | | `on_match` | After a rule has matched but before the verdict is returned. | Change the action (`ban` → `captcha`), set a custom HTTP response, append context for scenarios. | | `post_eval` | After all rules have evaluated. | Log enrichment; rarely modifies the verdict. | +| `on_challenge` | Bot detection only: a request carrying a valid challenge cookie, before `pre_eval`. | Re-check the fingerprint, escalate difficulty, drop. | +| `on_challenge_submit` | Bot detection only: at the challenge `/submit` POST, before the cookie is issued. | Score the fingerprint, reject the submission. | + +`on_challenge` and `on_challenge_submit` belong to bot detection, which also adds two top-level +appsec-config keys (`challenge:` and `data:` with `type: bots`): + +| You want to… | Go to | +|---|---| +| Stop challenging a path, host, or a crawler you trust | [bot-detection/customize.md](./bot-detection/customize.md) § Let legitimate traffic through | +| Score a fingerprint signal differently, or reject on your own criteria | [bot-detection/customize.md](./bot-detection/customize.md) § Catch bots the shipped scoring misses | +| Share challenge cookies across several engines, or change cookie lifetime | [bot-detection/configure.md](./bot-detection/configure.md) | +| Know which hook exposes which helper | [bot-detection/customize.md](./bot-detection/customize.md) § Which hook, which helper | Hooks are written in the `expr` language. They are deterministic and must not perform I/O. Errors in hooks bubble to the agent log and (for `pre_eval` / `on_match`) can drop or duplicate a request — test thoroughly with `cscli explain` (where supported) before enabling in production. diff --git a/skills/crowdsec/references/appsec/deploy.md b/skills/crowdsec/references/appsec/deploy.md index 33ba944..91719e9 100644 --- a/skills/crowdsec/references/appsec/deploy.md +++ b/skills/crowdsec/references/appsec/deploy.md @@ -128,6 +128,9 @@ The smoke test above proves the WAF works. For production you point a real bounc | `github.com/hslatman/caddy-crowdsec-bouncer` (Caddy module) | Two handlers required in the Caddy route — **`appsec` AND `crowdsec`** (see critical note below). The `appsec_url` field goes in the top-level `crowdsec` app config block. | | Any other AppSec-aware bouncer | Look for an `appsec_url` / `appsec.url` field; auth is always the bouncer's existing API key. | +Bot detection needs a bouncer that explicitly supports it — the endpoint alone is not enough. +See [bot-detection/deploy.md](./bot-detection/deploy.md) § 3 for the support matrix. + After wiring: send a request through the real web server (not directly to 7422) and confirm the verdict propagates. The bouncer's own log should show one line per consultation; AppSec's `cscli metrics show appsec` increments. > **Critical — Caddy (`hslatman/caddy-crowdsec-bouncer`) requires TWO handlers:** diff --git a/skills/crowdsec/references/appsec/overview.md b/skills/crowdsec/references/appsec/overview.md index 223927d..9105a95 100644 --- a/skills/crowdsec/references/appsec/overview.md +++ b/skills/crowdsec/references/appsec/overview.md @@ -24,9 +24,11 @@ client ──► web server / ingress │ │ │ verdict ◄─────┘ ▼ - allow / 403 / captcha + allow / 403 / captcha / challenge ``` +`challenge` is the bot-detection verdict — a proof-of-work + device-fingerprint page the engine serves through the bouncer. See [bot-detection/](./bot-detection/). + The AppSec endpoint listens on whatever address the AppSec acquisition file sets (`listen_addr`). Default is loopback; production deployments often expose it on a private interface so several bouncers can reach a single AppSec. `listen_addr` is interpreted per environment: bare-metal binds the host address directly; Docker needs `0.0.0.0` so the published port is reachable; Kubernetes reaches it via the AppSec Service DNS. See [deploy.md](./deploy.md). ## Terminology @@ -38,6 +40,7 @@ The AppSec endpoint listens on whatever address the AppSec acquisition file sets | **appsec-rule** | A single matching rule (e.g. `crowdsecurity/vpatch-CVE-2017-9841`). Hub items in `/etc/crowdsec/appsec-rules/`. Authoring is out of this skill's scope — see SKILL.md. | | **inband rule** | Matches a request and returns `block` **immediately**. Produces a `kind: waf` alert with `Remediation: false` (no default ban — the 403 is already enforced per-request). Visibility: `cscli metrics show appsec` and `cscli alerts list`. | | **out-of-band rule** | Matches a request and emits an event into the regular scenarios/buckets pipeline, the same way log-derived events do. Produces normal `kind: crowdsec` alerts and (via profiles) decisions. | +| **challenge** | The bot-detection verdict: the engine serves a JS proof-of-work and fingerprint page instead of blocking. Not a captcha, no third-party provider. See [bot-detection/overview.md](./bot-detection/overview.md). | | **remediation bouncer** | The component that actually enforces the verdict. AppSec doesn't block traffic — bouncers do. | ## Protocol — what the bouncer sends diff --git a/skills/crowdsec/references/appsec/troubleshoot.md b/skills/crowdsec/references/appsec/troubleshoot.md index b8da0a1..0c28617 100644 --- a/skills/crowdsec/references/appsec/troubleshoot.md +++ b/skills/crowdsec/references/appsec/troubleshoot.md @@ -151,9 +151,11 @@ Order of fixes from cheapest to most invasive: Do **not** whitelist by client IP at the scenarios layer for inband false-positives — scenarios-layer whitelists and **allowlists** (see [../configure/allowlists.md](../configure/allowlists.md)) only affect *decisions*. The inband 403 fires before any decision is written, so neither tool helps with inband false positives. Fix at the appsec-config. -## 7. Captcha doesn't appear (only 403 / blank page) +## 7. Captcha or challenge doesn't appear (only 403 / blank page) -AppSec returns the *verdict* (`captcha`); the bouncer is responsible for rendering it. Causes: +For the **bot-detection challenge**, go to [bot-detection/troubleshoot.md](./bot-detection/troubleshoot.md). + +For **captcha**: AppSec returns the *verdict* (`captcha`); the bouncer is responsible for rendering it. Causes: - Bouncer has no captcha provider configured (hCaptcha / reCAPTCHA / Turnstile keys missing). - `bouncer_blocked_http_code` and `bouncer_passthrough_http_code` in the appsec-config mismatch what the bouncer expects. diff --git a/skills/crowdsec/references/configure/bouncers/web-servers.md b/skills/crowdsec/references/configure/bouncers/web-servers.md index 3bc4df6..132d906 100644 --- a/skills/crowdsec/references/configure/bouncers/web-servers.md +++ b/skills/crowdsec/references/configure/bouncers/web-servers.md @@ -28,13 +28,17 @@ Both are served by the **same bouncer API key**. Wiring the WAF is just pointing Jump to the section for your web server. The shared model above (decisions + optional WAF, one key) and the stream-lag / real-IP pitfalls recur across all of them. -| Section | Package / module | WAF (AppSec)? | -|---|---|---| -| § nginx | `crowdsec-nginx-bouncer` (lua) | ✅ | -| § haproxy | `crowdsec-haproxy-spoa-bouncer` (SPOA) | ✅ | -| § apache | `crowdsec-apache2-bouncer` (`mod_crowdsec`) | ❌ decisions only | -| § Traefik | `crowdsec-bouncer-traefik-plugin` (Yaegi middleware) | ✅ | -| § Caddy | `caddy-crowdsec-bouncer` (compiled-in module) | ✅ | +| Section | Package / module | WAF (AppSec)? | Bot detection? | +|---|---|---|---| +| § nginx | `crowdsec-nginx-bouncer` (lua) | ✅ | ✅ (verified on 1.2.2) | +| § haproxy | `crowdsec-haproxy-spoa-bouncer` (SPOA) | ✅ | ✅ (untested here) | +| § apache | `crowdsec-apache2-bouncer` (`mod_crowdsec`) | ❌ decisions only | ❌ | +| § Traefik | `crowdsec-bouncer-traefik-plugin` (Yaegi middleware) | ✅ | ✅ (untested here) | +| § Caddy | `caddy-crowdsec-bouncer` (compiled-in module) | ✅ | ❌ | + +Bot detection (AppSec challenge mode) needs more from the bouncer than the WAF does — +see [../../appsec/bot-detection/deploy.md](../../appsec/bot-detection/deploy.md) § 3. Upstream +publishes no minimum bouncer version for it. ## nginx — `crowdsec-nginx-bouncer` diff --git a/skills/crowdsec/references/configure/profiles.md b/skills/crowdsec/references/configure/profiles.md index fa21f82..964eced 100644 --- a/skills/crowdsec/references/configure/profiles.md +++ b/skills/crowdsec/references/configure/profiles.md @@ -77,6 +77,10 @@ on_success: break | `captcha` | Serve a challenge | Only **web-server / AppSec bouncers** can render captcha; a firewall bouncer can't and treats it as no-op. Needs captcha provider config. | | `throttle` | Rate-limit | Bouncer-dependent support. | +`challenge` (bot detection) is **not** a decision type and cannot be set here — it is an +AppSec action decided per request by an appsec-config. See +[../appsec/bot-detection/overview.md](../appsec/bot-detection/overview.md). + ### Escalation with `duration_expr` Longer bans for repeat offenders — uncomment in the default profile: From c411d6d537eb2515e98dcbf33794db29e5445037 Mon Sep 17 00:00:00 2001 From: "Thibault \"bui\" Koechlin" <orixxx@gmail.com> Date: Tue, 1 Sep 2026 10:00:12 +0200 Subject: [PATCH 2/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- skills/crowdsec/references/appsec/bot-detection/configure.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/crowdsec/references/appsec/bot-detection/configure.md b/skills/crowdsec/references/appsec/bot-detection/configure.md index 34d3401..3f497f0 100644 --- a/skills/crowdsec/references/appsec/bot-detection/configure.md +++ b/skills/crowdsec/references/appsec/bot-detection/configure.md @@ -19,7 +19,7 @@ field with last-non-nil winning — so never edit the hub files. Ship a small ov name: mycorp/appsec-bot-challenge-overlay challenge: - master_secret: "efb7789044ed092ae90c139848be7687652920064dccc40db1eeb926a8050930" + master_secret: "<64+ hex chars; generate with: openssl rand -hex 32>" cookie_ttl: 2h ``` From 0a2e3156f1fc523251fc18191eb714391cd73627 Mon Sep 17 00:00:00 2001 From: Thibault Koechlin <orixxx@gmail.com> Date: Tue, 1 Sep 2026 10:34:52 +0200 Subject: [PATCH 3/4] lighten changes --- CHANGELOG.md | 1 - .../appsec/bot-detection/configure.md | 85 ++--- .../appsec/bot-detection/customize.md | 334 +++++------------- .../references/appsec/bot-detection/deploy.md | 220 ++++-------- .../appsec/bot-detection/overview.md | 150 +++----- .../appsec/bot-detection/troubleshoot.md | 286 +++++---------- .../crowdsec/references/configure/profiles.md | 4 - 7 files changed, 340 insertions(+), 740 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee3905b..30c8a95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `references/appsec/{overview,configure,deploy,troubleshoot}.md` — `challenge` action, `on_challenge` / `on_challenge_submit` hooks, and pointers into `bot-detection/`. - `references/configure/bouncers/web-servers.md` — per-bouncer bot-detection capability column. -- `references/configure/profiles.md` — note that `challenge` is not a LAPI decision type. ## [0.2.3] - 2026-05-29 diff --git a/skills/crowdsec/references/appsec/bot-detection/configure.md b/skills/crowdsec/references/appsec/bot-detection/configure.md index 3f497f0..ccb6791 100644 --- a/skills/crowdsec/references/appsec/bot-detection/configure.md +++ b/skills/crowdsec/references/appsec/bot-detection/configure.md @@ -6,13 +6,14 @@ verified: notes: "challenge: block via overlay — master_secret silences the warning, cookie_ttl 2h applied; difficulty levels + invalid-level error" --- -# Tune the challenge runtime +# Challenge runtime configuration -Canonical docs: <https://docs.crowdsec.net/docs/next/appsec/bot_detection/configuration> +Canonical: <https://docs.crowdsec.net/docs/next/appsec/bot_detection/configuration> -The `challenge:` block is a **top-level key in any appsec-config** (a sibling of `name:` and -`inband:`, not nested under them). Every loaded appsec-config contributes, merged field by -field with last-non-nil winning — so never edit the hub files. Ship a small overlay instead: +`challenge:` is a **top-level key in any appsec-config** (sibling of `name:` and `inband:`). +Every loaded appsec-config contributes, merged field by field, last non-nil winning. Never edit +hub files — ship an overlay and list it by name in `appsec_configs:` +([./deploy.md](./deploy.md) § 2): ```yaml # /etc/crowdsec/appsec-configs/mycorp-overlay.yaml @@ -23,49 +24,34 @@ challenge: cookie_ttl: 2h ``` -and list it by name in `appsec_configs:` — the `crowdsecurity/appsec-bot-*` glob will not -pick up your namespace ([./deploy.md](./deploy.md) § 2). - -## Keys - -| Key | Default | What it does | +| Key | Default | Purpose | |---|---|---| -| `master_secret` | random, ephemeral | Root of every derived signing key and the cookie-sealing key. Hex ≥64 chars (preferred) or a passphrase ≥32 bytes. | -| `key_rotation_interval` | `5m` | How often the per-epoch signing key advances. Minimum 30s. | -| `max_live_epochs` | `3` | Past epochs still accepted, so a rotation does not invalidate in-flight submissions. A client has `(max_live_epochs + 1) × key_rotation_interval` to solve. | -| `cookie_ttl` | `12h` | Success-cookie lifetime. Sealed under a separate long-lived key, so key rotation does **not** invalidate issued cookies. | -| `crypto_obfuscation_pool_size` | `1` | Distinct obfuscations of the per-epoch key module, for per-visitor variance. Costs roughly 5s CPU per variant per rotation. | -| `max_cookie_size` | `4096` | Byte cap on the sealed cookie, enforced on seal and open. Bounds an over-allocation DoS. Raise only for non-browser clients. | -| `spent_set_max_entries` | `1000000` | Replay-protection LRU — a solved challenge cannot be submitted twice. | -| `log_level` | inherits global | Verbosity of the challenge runtime only. See [./troubleshoot.md](./troubleshoot.md) § 8. | +| `master_secret` | random, ephemeral | Root of every derived signing key and the cookie-sealing key. Hex ≥64 chars, or passphrase ≥32 bytes. | +| `key_rotation_interval` | `5m` | Per-epoch signing key advance. Minimum 30s. | +| `max_live_epochs` | `3` | Past epochs still accepted. A client has `(max_live_epochs + 1) × key_rotation_interval` to solve. | +| `cookie_ttl` | `12h` | Success-cookie lifetime. Sealed under a separate long-lived key, so key rotation does not invalidate issued cookies. | +| `crypto_obfuscation_pool_size` | `1` | Obfuscation variants of the per-epoch key module. ~5s CPU per variant per rotation. | +| `max_cookie_size` | `4096` | Byte cap on the sealed cookie, enforced on seal and open. Bounds an over-allocation DoS. | +| `spent_set_max_entries` | `1000000` | Replay-protection LRU — a solved challenge cannot be resubmitted. | +| `log_level` | inherits global | Challenge runtime only — [./troubleshoot.md](./troubleshoot.md) § 8. | -Confirm what actually took effect — the runtime logs its resolved settings at startup: +Resolved settings are logged at startup: ``` level=info msg="WAF challenge runtime initialized" cookie_ttl=2h0m0s crypto_pool_size=1 max_cookie_len=4096 module=challenge pow_difficulty=20 rotation_interval=5m0s ``` -## Multi-instance — the one that bites - -With no `master_secret`, each instance generates a random one at startup and every instance -rejects the others' cookies, so clients re-challenge on every request that lands on a -different node. Restarts have the same effect on a single node. The engine warns: - -``` -level=warning msg="no master secret configured for the WAF challenge runtime; generated an ephemeral random secret. Distributed (multi-WAF) deployments MUST configure a shared master_secret in the appsec config; single-instance deployments will see outstanding challenge cookies invalidated on restart." module=challenge -``` - -Behind a load balancer, or on more than one AppSec instance, set it — and set -`key_rotation_interval` and `max_live_epochs` identically everywhere, since keys derive from -both. +## Multi-instance -```bash -openssl rand -hex 32 -``` +Without `master_secret`, each instance generates a random one at startup and rejects the +others' cookies, so clients re-challenge on every request landing elsewhere; restarts do the +same on a single node (the engine warns — see [./troubleshoot.md](./troubleshoot.md) § 4). +Behind a load balancer or on more than one AppSec instance, set it with `openssl rand -hex 32`, +and set `key_rotation_interval` and `max_live_epochs` identically everywhere, since keys derive +from both. -Rotating the secret: roll the new value out to **every** instance inside one `cookie_ttl` -window, then restart each. During the rollout, clients holding old cookies are challenged -once more; they are not blocked. +Rotation: roll the new value to every instance inside one `cookie_ttl` window, then restart +each. Clients holding old cookies are challenged once more, not blocked. | Change | Invalidates | |---|---| @@ -75,15 +61,11 @@ once more; they are not blocked. | `crypto_obfuscation_pool_size` | Nothing — applies at the next rotation tick | Treat the secret as a credential: a Kubernetes `Secret` projected into the log-processor's -appsec-config, or a Docker secret / bind-mounted file — not a value committed next to the -manifest. The appsec-config itself is a plain file at -`/etc/crowdsec/appsec-configs/` (systemd), a mounted path in the same directory (docker), or -an entry in the log-processor ConfigMap (k8s). +appsec-config, or a Docker secret / bind-mounted file. ## Difficulty -Proof-of-work cost in leading zero bits. Calibrated for a low-end 2-core phone; a desktop is -roughly 20× faster. +PoW cost in leading zero bits, calibrated for a low-end 2-core phone; desktops are ~20× faster. | Level | Bits | Low-end mobile | Desktop | |---|---|---|---| @@ -91,17 +73,16 @@ roughly 20× faster. | `low` | 18 | ~0.5s | ~0.03s | | `medium` **(default)** | 20 | ~2s | ~0.10s | | `high` | 22 | ~8s | ~0.41s | -| `impossible` | 256 | never solvable — a deliberate hard block, rejected server-side with no reason leaked | +| `impossible` | 256 | never solvable — hard block, rejected server-side, no reason leaked | | -There is no `challenge:` key for the default level. Set it per request from a hook — +No `challenge:` key sets the default level; set it per request with `SetChallengeDifficulty("high")` in `pre_eval` or `on_challenge` -([./customize.md](./customize.md)). An unknown level is not a startup error; it fails per -request at runtime and the request is still challenged at the default: +([./customize.md](./customize.md)). An unknown level is a per-request runtime error, not a +startup failure, and the request is still challenged at the default: ``` level=error msg="unable to apply appsec pre_eval[inband] expr: unknown challenge difficulty \"extreme\" (expected disabled, low, medium, high, or impossible)" ``` -Raising difficulty is a poor bot deterrent on its own — a determined scraper has more CPU -than a phone. Use it to slow down abuse of a specific expensive route, and rely on -fingerprint scoring to actually identify bots. +Difficulty is a weak deterrent — a scraper has more CPU than a phone. Use it to slow abuse of a +specific expensive route; rely on fingerprint scoring to identify bots. diff --git a/skills/crowdsec/references/appsec/bot-detection/customize.md b/skills/crowdsec/references/appsec/bot-detection/customize.md index a931c44..d076fe1 100644 --- a/skills/crowdsec/references/appsec/bot-detection/customize.md +++ b/skills/crowdsec/references/appsec/bot-detection/customize.md @@ -3,40 +3,25 @@ verified: - date: 2026-08-31 version: "1.8.0" env: systemd - notes: "overlay deploy loop, ExemptFromChallenge, GrantChallengeCookie 307+TTL, SetChallengeDifficulty, MatchKnownBot + mandatory data: block, hook-placement errors, scoring points read from installed appsec-bot-challenge-scoring 0.2 (hub config is the source of truth), custom scenario on both evt.Meta and evt.Parsed paths" + notes: "overlay deploy loop, ExemptFromChallenge, GrantChallengeCookie 307+TTL, SetChallengeDifficulty, MatchKnownBot + mandatory data: block, hook-placement errors, scoring points read from installed appsec-bot-challenge-scoring 0.2, custom scenario on both evt.Meta and evt.Parsed paths" --- -# Write your own bot-detection config +# Custom bot-detection configs -Canonical docs: <https://docs.crowdsec.net/docs/next/appsec/bot_detection/customization> · -[hooks reference](https://docs.crowdsec.net/docs/next/appsec/bot_detection/hooks) +Canonical: <https://docs.crowdsec.net/docs/next/appsec/bot_detection/customization> · +[hooks](https://docs.crowdsec.net/docs/next/appsec/bot_detection/hooks) -Two jobs: **let legitimate traffic through**, and **catch bots the shipped scoring misses**. -Both are done with your own appsec-config — never by editing the hub files, which a -`cscli hub upgrade` will overwrite. +Two jobs: let legitimate traffic through, and catch bots the shipped scoring misses. Both via +your own appsec-config — never by editing hub files (`cscli hub upgrade` overwrites them). -## The deploy loop +## Deploy loop -1. Write the file to `/etc/crowdsec/appsec-configs/` (docker: a mounted path in the same - directory; k8s: the log-processor ConfigMap). -2. Add its `name:` to `appsec_configs:` in the acquisition — **by name**. The - `crowdsecurity/appsec-bot-*` glob does not match your namespace. -3. Reload, then `cscli appsec-configs list` to confirm it is loaded. - -```yaml -listen_addr: 127.0.0.1:7422 -appsec_configs: - - crowdsecurity/appsec-bot-* - - mycorp/appsec-bot-challenge-overlay -labels: - type: appsec -source: appsec -``` - -**Order matters.** Configs load in the order listed. A config that re-weights scores must -load *after* `crowdsecurity/appsec-bot-challenge-scoring` and *before* the threshold config, -because `RejectSubmission()` is terminal — it halts every remaining -`on_challenge_submit` rule. +Write to `/etc/crowdsec/appsec-configs/` (docker: mounted path in the same directory; k8s: +log-processor ConfigMap), add its `name:` to `appsec_configs:` **by name** (the +`crowdsecurity/appsec-bot-*` glob does not match your namespace), reload, confirm with +`cscli appsec-configs list`. Configs load in listed order: a re-weighting config must load +**after** `crowdsecurity/appsec-bot-challenge-scoring` and **before** the threshold config, +because `RejectSubmission()` is terminal — it halts every remaining `on_challenge_submit` rule. ## Which hook, which helper @@ -44,137 +29,76 @@ because `RejectSubmission()` is terminal — it halts every remaining |---|---|---| | `pre_eval` | Before in-band WAF rules, every request | `ExemptFromChallenge` · `GrantChallengeCookie` · `SetChallengeDifficulty` · `MatchKnownBot` · `DropRequest` · `AddRequestScore` | | `post_eval` | After in-band WAF rules | `SendChallenge` · `SetChallengeDifficulty` · `GrantChallengeCookie` · `ExemptFromChallenge` · `DumpFingerprint` | -| `on_challenge` | Request carrying a valid cookie, before `pre_eval`; `fingerprint` populated | `SendChallenge` · `EvaluateMismatches` · `SetChallengeDifficulty` · `SetRemediation` · `SetReturnCode` · `DropRequest` | -| `on_challenge_submit` | At the `/submit` POST, after crypto validation, before the cookie is issued | `RejectSubmission` · `GrantChallengeCookie` · `LogAccepted` · `EvaluateMismatches` · `DumpFingerprint` · `CancelAlert` | - -Score helpers (`AddRequestScore`, `RequestScore`, `RequestScoreReasons`, -`RequestScoreDetail`, `RequestScoreFor`) are available in all of the above plus `on_match`. +| `on_challenge` | Request with a valid cookie, before `pre_eval`; `fingerprint` populated | `SendChallenge` · `EvaluateMismatches` · `SetChallengeDifficulty` · `SetRemediation` · `SetReturnCode` · `DropRequest` | +| `on_challenge_submit` | At `/submit`, after crypto validation, before cookie issuance | `RejectSubmission` · `GrantChallengeCookie` · `LogAccepted` · `EvaluateMismatches` · `DumpFingerprint` · `CancelAlert` | -Two placement rules the engine enforces at startup: +Score helpers (`AddRequestScore`, `RequestScore`, `RequestScoreReasons`, `RequestScoreDetail`, +`RequestScoreFor`) work in all of the above plus `on_match`. `on_challenge_submit` exposes +nothing that changes the response shape (`SendChallenge`, `SetRemediation`, `SetReturnCode`, +`DropRequest`) — it answers a JSON handshake the client JS parses; escalate from `pre_eval` on +the next request instead. Placement is enforced at startup: -- **`SendChallenge()` is not available in `pre_eval`** — a challenge may only be issued after - in-band evaluation. Using it there is a fatal config error: - ``` - FATAL … unable to build pre_eval hook : unable to compile apply SendChallenge() : unknown name SendChallenge (1:1) - ``` -- **`on_challenge` and `on_challenge_submit` are in-band only.** Under `outofband:`: - ``` - FATAL … on_challenge hooks are only valid in-band, not under outofband - ``` - -`on_challenge_submit` deliberately exposes no helper that changes the response shape -(`SendChallenge`, `SetRemediation`, `SetReturnCode`, `DropRequest`) — it answers a JSON -handshake the client-side JS is parsing. Escalate from `pre_eval` on the *next* request -instead. +| Mistake | Error | +|---|---| +| `SendChallenge()` in `pre_eval` | `unable to build pre_eval hook : unable to compile apply SendChallenge() : unknown name SendChallenge (1:1)` | +| `on_challenge*` under `outofband:` | `on_challenge hooks are only valid in-band, not under outofband` | ## Let legitimate traffic through -Four mechanisms. Pick by what you can prove about the client: - | You can prove | Use | Scope | |---|---|---| -| Nothing — it is just this path/host | `ExemptFromChallenge(reason)` | Per request, no cookie | -| A shared secret or header | `GrantChallengeCookie(reason, ttl?)` | Persists via cookie, 307 redirect | -| Published IP ranges or rDNS (a real crawler) | `MatchKnownBot(...)` + a `data:` file | Per request | -| A fixed source IP you control | A native LAPI allowlist | Bypasses AppSec entirely | - -A LAPI allowlist is the bluntest and often the right answer for your own infrastructure — an -allowlisted IP never reaches AppSec at all, is never challenged and never issued a cookie. -See [../../configure/allowlists.md](../../configure/allowlists.md). - -### Scope the challenge to what matters +| Nothing — just this path/host | `ExemptFromChallenge(reason)` | Per request, no cookie | +| A shared secret / header | `GrantChallengeCookie(reason, ttl?)` | Persists via cookie, 307 redirect | +| Published IP ranges or rDNS | `MatchKnownBot(...)` + a `data:` file | Per request | +| A fixed source IP you control | LAPI allowlist | Bypasses AppSec entirely | -Challenging your whole site is rarely what you want. Invert it — exempt everything except the -funnel you care about: +A LAPI allowlisted IP never reaches AppSec, is never challenged, never issued a cookie — +[../../configure/allowlists.md](../../configure/allowlists.md). Exemptions are counted by +reason in `cscli metrics show bot-detection`. ```yaml -name: mycorp/appsec-bot-challenge-checkout-only +name: mycorp/appsec-bot-challenge-overlay inband: pre_eval: + # Exempt everything except the funnel you care about. - filter: '!(req.URL.Path startsWith "/checkout/")' apply: - ExemptFromChallenge("outside-checkout") -``` - -Per host, for a multi-vhost listener: - -```yaml -name: mycorp/appsec-bot-challenge-protected-surface -inband: - pre_eval: - - filter: req.Host != "foobar.com" && req.Host != "api.foobar.com" - apply: - - ExemptFromChallenge("unprotected-host") + # Per host. Proxies may forward "foobar.com:443" — use startsWith, or + # req.Host == "foobar.com" || req.Host endsWith ".foobar.com" for a whole domain. - filter: req.Host == "api.foobar.com" && !(req.URL.Path startsWith "/v1/login") apply: - ExemptFromChallenge("api-outside-login") -``` - -Proxies may forward `foobar.com:443` rather than the bare host — use `startsWith`, or -`req.Host == "foobar.com" || req.Host endsWith ".foobar.com"` for a whole domain. - -Verify with the exempt metric, which counts by reason: - -``` -| Bot Detection — Exempted | -| Appsec Engine | Reason | Count | -| 127.0.0.1:7422/ | internal-health | 1 | -| 127.0.0.1:7422/ | crawler-files | 3 | -``` - -### An internal probe - -```yaml -name: mycorp/appsec-bot-challenge-overlay -inband: - pre_eval: + # A shared-secret header is a bypass token if it leaks — always pair it with a source + # constraint; prefer a LAPI allowlist when the IP is stable. - filter: req.Header.Get("X-Internal-Probe") == "s3cr3t" && req.RemoteAddr startsWith "10." apply: - - GrantChallengeCookie("internal-probe", "24h") + - GrantChallengeCookie("internal-probe", "24h") # omit TTL to use cookie_ttl ``` -The response is a `307` carrying the cookie, so the client lands where it was going: +`GrantChallengeCookie` answers `307` with `Location` and +`__crowdsec_challenge=…; Path=/; Max-Age=86399; HttpOnly; SameSite=Lax`. -```json -{ - "action": "challenge", - "http_status": 307, - "user_cookies": ["__crowdsec_challenge=ABme-J7g…; Path=/; Max-Age=86399; HttpOnly; SameSite=Lax"], - "user_headers": { "Location": ["/"], "Cache-Control": ["no-store"] } -} -``` - -A shared-secret header is a bypass token the moment it leaks. Always pair it with a source -constraint as above, and prefer a LAPI allowlist when the source IP is stable. - -Drop the `, "24h"` to use the configured `cookie_ttl`. Use `ExemptFromChallenge` instead if -you want no cookie at all. - -### A crawler of your own +### Your own crawler -`MatchKnownBot` checks a JSON datafile of bot definitions: - -``` -(user_agent matches AND at least one path matches) AND (exact IP OR CIDR range OR FCrDNS) -``` - -Newline-delimited JSON, one definition per line, landing under -`<datadir>/legit_bots/` (usually `/var/lib/crowdsec/data/legit_bots/`): +`MatchKnownBot` matches +`(user_agent matches AND at least one path matches) AND (exact IP OR CIDR range OR FCrDNS)` +against newline-delimited JSON under `<datadir>/legit_bots/` (usually +`/var/lib/crowdsec/data/legit_bots/`): ```json {"name":"mybot","user_agent":"mycorp-monitor","paths":["^/health(/|$)","^/status$"],"ranges":["10.42.0.0/16"],"ips":["192.0.2.77"]} ``` -`name` is required. `user_agent` and `paths` are regexes; omitting them matches anything. At -least one of `ips` / `ranges` / `rdns` is required — a user-agent-only definition is rejected -at load, because a UA is trivially spoofed. `rdns` entries are forward-confirmed reverse DNS -regexes; anchor them (`(^|\.)googlebot\.com$`), or `evilgooglebot.com` matches. Parse, DNS -and unknown-file errors all fail closed — the bot is challenged. +`name` required. `user_agent` / `paths` are regexes; omitted means "match anything". At least +one of `ips` / `ranges` / `rdns` is required — UA-only definitions are rejected at load. Anchor +`rdns` regexes (`(^|\.)googlebot\.com$`) or `evilgooglebot.com` matches. Parse, DNS and +unknown-file errors fail closed (bot is challenged). FCrDNS needs working reverse DNS from the +engine; the DNS cache is global engine config (`crowdsec_service` → `dns_cache`). -> **The `data:` block is mandatory.** Dropping the file into `legit_bots/` by hand does -> nothing: only files declared in `data:` are registered in the expr datafile registry, and -> `MatchKnownBot` silently returns false for anything else. Verified — the same config -> matched nothing until the `data:` block was added. +> **The `data:` block is mandatory.** Only files declared there are registered in the expr +> datafile registry; `MatchKnownBot` silently returns false for anything else. Verified — the +> same config matched nothing until the block was added. ```yaml name: mycorp/appsec-bot-challenge-known-bot @@ -189,137 +113,77 @@ data: type: bots ``` -Behaviour of the definition above, verified: +Verified with that definition: `/status` from `10.42.0.9` or `192.0.2.77` with UA +`mycorp-monitor/1.0` is exempt (range / exact IP); the same UA from `1.2.3.4` is challenged (UA +alone proves nothing); `/other` from `10.42.0.9` is challenged (path not listed). -| Request | Result | -|---|---| -| `/status` from `10.42.0.9`, UA `mycorp-monitor/1.0` | exempt — range + UA + path | -| `/status` from `192.0.2.77`, same UA | exempt — exact IP | -| `/status` from `1.2.3.4`, same UA | challenged — UA alone proves nothing | -| `/other` from `10.42.0.9`, same UA | challenged — path not listed | +## Catch bots the scoring misses -FCrDNS needs working reverse DNS resolution from the engine. The DNS cache is global engine -config (`crowdsec_service` → `dns_cache`), not part of the appsec-config. - -## Catch bots the shipped scoring misses - -`EvaluateMismatches()` returns a cached-per-request report over the fingerprint: - -| Call | Returns | -|---|---| -| `.Has("cdp")` | Did this signal fire | -| `.Count()` | How many fired | -| `.High()` / `.Medium()` / `.Low()` | Count by severity | -| `.Reasons()` | The signal names | - -### What a signal is worth +`EvaluateMismatches()` returns a cached-per-request report: `.Has("cdp")`, `.Count()`, +`.High()` / `.Medium()` / `.Low()`, `.Reasons()`. **Points live in the hub config, not the engine.** `crowdsecurity/appsec-bot-challenge-scoring` -is the source of truth for what each signal contributes, and it is a normal hub item — it gets -revised, and you can override it. Always read the installed file rather than trusting a table: - -```bash -sudo grep -B2 AddRequestScore /etc/crowdsec/appsec-configs/appsec-bot-challenge-scoring.yaml -``` - -As shipped in `appsec-bot-challenge-scoring` **0.2**: - -| Points | Signals | Rationale in the config | -|---|---|---| -| **100** | `cdp`, `webdriver`, `webdriver_writable`, `selenium`, `playwright`, `webdriver_iframe`, `webdriver_worker`, `bot_user_agent` | Declared automation. One is enough to clear every shipped threshold. | -| **50** | `headless_screen_resolution`, `missing_chrome_object`, `impossible_memory`, `inconsistent_etsl`, `mismatch_webgl_worker`, `mismatch_platform_iframe`, `mismatch_platform_worker` | Headless environments and cross-context inconsistencies — hard to fake away, thin tail of legitimate clients. | -| **30** | `platform_mismatch`, `gpu_mismatch`, `high_cpu_count` | Suspicious, but reachable by odd-but-real setups (VMs, remote desktops). | -| **15** | `utc_timezone`, `ua_mobile`, `accept_language` | Common enough among real visitors that acting on one alone is a false positive. | -| **5** | `swiftshader_renderer`, `mismatch_languages`, `timezone_country` | Only meaningful in aggregate. | +is the source of truth — a normal hub item that gets revised and can be overridden; read it +with `sudo grep -B2 AddRequestScore /etc/crowdsec/appsec-configs/appsec-bot-challenge-scoring.yaml`. +As shipped in **0.2**: -### Severity is a different axis - -`.High()` / `.Medium()` / `.Low()` do **not** read those points. Severity is a fixed label the -engine attaches to each signal, and the two do not line up: every signal from 30 points upward -is `high`, only the 15-point ones are `medium`, only the 5-point ones are `low`. - -So `EvaluateMismatches().High() >= 1` fires on a lone `gpu_mismatch` — a 30-point signal, -nowhere near any shipped threshold. If you mean "strong evidence", filter on `RequestScore()` -or name the signals with `.Has(...)`; reach for `.High()` only when you genuinely want any -high-severity signal regardless of weight. +| Points | Signals | +|---|---| +| **100** | `cdp`, `webdriver`, `webdriver_writable`, `selenium`, `playwright`, `webdriver_iframe`, `webdriver_worker`, `bot_user_agent` — declared automation; one clears every threshold | +| **50** | `headless_screen_resolution`, `missing_chrome_object`, `impossible_memory`, `inconsistent_etsl`, `mismatch_webgl_worker`, `mismatch_platform_iframe`, `mismatch_platform_worker` — headless / cross-context inconsistencies | +| **30** | `platform_mismatch`, `gpu_mismatch`, `high_cpu_count` — reachable by VMs and remote desktops | +| **15** | `utc_timezone`, `ua_mobile`, `accept_language` — common among real visitors | +| **5** | `swiftshader_renderer`, `mismatch_languages`, `timezone_country` — only meaningful in aggregate | -Re-weight a signal for your traffic — load this **between** the scoring config and the -threshold config: +**Severity is a different axis.** `.High()` / `.Medium()` / `.Low()` do not read those points — +severity is a fixed engine label, and everything from 30 points upward is `high`, 15 is +`medium`, 5 is `low`. So `.High() >= 1` fires on a lone `gpu_mismatch`, far below any threshold. +Use `RequestScore()` or `.Has(...)` when you mean "strong evidence". ```yaml -name: mycorp/appsec-bot-challenge-reweight inband: on_challenge_submit: - # A UTC-only audience is unusual for us; treat it as stronger evidence. + # Re-weight: load between the scoring and threshold configs. Scores may be negative. - filter: EvaluateMismatches().Has("utc_timezone") apply: - AddRequestScore(45, "utc_timezone_mycorp") - # Our app is mobile-first, so a mobile UA mismatch is expected noise. - filter: EvaluateMismatches().Has("ua_mobile") apply: - AddRequestScore(-15, "ua_mobile_expected") -``` - -Scores may be negative. Add application-specific evidence the fingerprint cannot see: - -```yaml + # Evidence the fingerprint cannot see. - filter: req.URL.Path startsWith "/checkout" && req.Header.Get("Referer") == "" apply: - AddRequestScore(30, "checkout_no_referer") -``` - -Reject on signal shape rather than score: - -```yaml + # Reject on signal shape rather than score. Optional 2nd arg "minimal" / "info" + # (default) / "verbose" controls logged fingerprint detail. - filter: EvaluateMismatches().High() >= 1 apply: - 'RejectSubmission("high-severity mismatch")' -``` - -`RejectSubmission(reason, verbosity)` takes an optional `"minimal"` / `"info"` (default) / -`"verbose"` controlling how much fingerprint detail is logged. - -Escalate instead of rejecting — make a suspicious client work harder on its next challenge: - -```yaml on_challenge: + # Escalate instead of rejecting. - filter: EvaluateMismatches().High() >= 1 apply: - SetChallengeDifficulty("high") - SendChallenge() -``` - -Or hard-drop a client that carries a valid cookie but now shows automation signals: - -```yaml - on_challenge: + # Drop a cookie-bearing client that now shows automation. - filter: fingerprint.HasAutomationSignal() apply: - 'DropRequest("automation signal on cookie-bearing client")' ``` -The `fingerprint` object also offers `IsBot()`, `HasBotSignal()`, `BotSignalCount()`, -`BotSignals()`, `HasHeadlessSignal()`, `HasMismatchSignal()`, `Platform()`, `Timezone()`, -`Language()`, `IsMobile()`, `CPUCount()`, `Memory()`. Full shape: +`fingerprint` also offers `IsBot()`, `HasBotSignal()`, `BotSignalCount()`, `BotSignals()`, +`HasHeadlessSignal()`, `HasMismatchSignal()`, `Platform()`, `Timezone()`, `Language()`, +`IsMobile()`, `CPUCount()`, `Memory()` — <https://pkg.go.dev/github.com/crowdsecurity/crowdsec/pkg/appsec/challenge#FingerprintData> ## Custom scenarios on challenge events -A persistent bot is already covered without any custom scenario. A rejected submission is -still a submission, so it emits `submitted` as well as `rejected`, and -`crowdsecurity/appsec-bot-challenge-too-many-submissions` fills on it — -verified: eight consecutive CDP-browser rejections produced a ban from that scenario, not -just alerts. A real browser solves once, takes its cookie and stops submitting, so it does -not trip the same bucket. - -What the shipped scenarios do *not* give you is a rejection-specific reaction: no scenario -filters on `challenge_event` being `rejected`, `failed` or `solved`. Write one when you want -to ban faster than raw submission volume allows, react to a particular signal, or treat a -single rejection as terminal for a sensitive route. - -Both field paths work; the shipped scenarios use `evt.Meta`, upstream docs teach -`evt.Parsed`. `evt.Meta` fields are the ones the hub parser sets, so prefer them for -consistency with the shipped content: +Persistent bots are already covered: a rejected submission still emits `submitted`, so +`crowdsecurity/appsec-bot-challenge-too-many-submissions` fills on it (verified — eight +consecutive CDP rejections produced a ban), while a real browser solves once, takes its cookie +and stops submitting. Write your own to ban faster than submission volume allows, react to a +specific signal, or treat one rejection as terminal for a sensitive route. Both `evt.Meta` and +`evt.Parsed` paths work; shipped scenarios use `evt.Meta`. ```yaml # /etc/crowdsec/scenarios/mycorp-appsec-bot-rejected.yaml @@ -339,22 +203,12 @@ labels: spoofable: 0 behavior: "http:bot" label: "Repeatedly failed the CrowdSec bot challenge" - remediation: true -``` - -Fields the parser exposes on the event: `source_ip`, `target_host`, `target_uri`, -`request_uuid`, `challenge_event`, `challenge_difficulty`, `challenge_fail_reason`, `fsid`, -`fingerprint_bot`, `http_user_agent`, `request_score`, `request_score_reasons`, `os`. - -`request_score_reasons` is a flat string like `"cdp=100,utc_timezone=15"`, so match on it -with `contains`: - -```yaml -filter: | - evt.Meta.log_type == 'appsec-challenge' && - evt.Meta.challenge_event == 'rejected' && - evt.Meta.request_score_reasons contains "cdp=" + remediation: true # what turns the alert into a decision, via your profiles ``` -`remediation: true` is what turns the alert into a decision through your profiles — +Parser-exposed fields: `source_ip`, `target_host`, `target_uri`, `request_uuid`, +`challenge_event`, `challenge_difficulty`, `challenge_fail_reason`, `fsid`, `fingerprint_bot`, +`http_user_agent`, `request_score`, `request_score_reasons`, `os`. `request_score_reasons` is a +flat string (`"cdp=100,utc_timezone=15"`) — match with +`evt.Meta.request_score_reasons contains "cdp="`. See [../../configure/profiles.md](../../configure/profiles.md). diff --git a/skills/crowdsec/references/appsec/bot-detection/deploy.md b/skills/crowdsec/references/appsec/bot-detection/deploy.md index 7e7758b..58b64e6 100644 --- a/skills/crowdsec/references/appsec/bot-detection/deploy.md +++ b/skills/crowdsec/references/appsec/bot-detection/deploy.md @@ -3,7 +3,7 @@ verified: - date: 2026-08-31 version: "1.8.0" env: systemd - notes: "full loop: collection install, glob acquisition, nginx bouncer 1.2.2 at default config, challenge served, CDP browser rejected, alert + metrics, too-many-submissions ban after repeated rejections; ALWAYS_SEND_TO_APPSEC semantics re-tested" + notes: "collection install, glob acquisition, nginx bouncer 1.2.2 at default config, challenge served, CDP browser rejected, alert + metrics, too-many-submissions ban; ALWAYS_SEND_TO_APPSEC semantics" - date: 2026-08-31 version: "1.8.0-rc2" env: docker @@ -12,75 +12,57 @@ verified: # Deploy bot detection -Canonical docs: <https://docs.crowdsec.net/docs/next/appsec/bot_detection/enable> · -[what's included](https://docs.crowdsec.net/docs/next/appsec/bot_detection/whats_included) - -Prerequisite: a working AppSec listener — [../deploy.md](../deploy.md). This doc only adds -bot detection on top. +Canonical: <https://docs.crowdsec.net/docs/next/appsec/bot_detection/enable> · +[what's included](https://docs.crowdsec.net/docs/next/appsec/bot_detection/whats_included). +Prerequisite: a working AppSec listener — [../deploy.md](../deploy.md). ## 1 — Pick exactly one bundle -| Collection | Loads threshold config | Rejects at score | +| Collection | Threshold config | Rejects at | |---|---|---| | `crowdsecurity/appsec-bot-challenge` | `…-scoring-balanced` | ≥ 75 | | `crowdsecurity/appsec-bot-challenge-strict` | `…-scoring-strict` | ≥ 45 | | `crowdsecurity/appsec-bot-challenge-permissive` | `…-scoring-permissive` | ≥ 100 | -Start with the balanced bundle. Each also pulls three building blocks you can install on -their own if you are assembling a custom stack: `…-scoring` (the scoring engine, never -rejects), `…-good-bots` (verified-crawler exclusions), `…-exclude-paths` (path exclusions). +Separately installable building blocks: `…-scoring` (scoring engine, never rejects), +`…-good-bots` (verified-crawler exclusions), `…-exclude-paths`. -> **Install one bundle, not several.** The `crowdsecurity/appsec-bot-*` glob below loads -> *every* threshold config you have installed, and their rejection rules stack — the -> strictest wins, silently overriding your choice. Verified: installing `-strict` alongside -> the default leaves both `appsec-bot-challenge-scoring-balanced.yaml` and -> `…-strict.yaml` loaded, so rejection happens at 45, not 75. Check with -> `cscli appsec-configs list | grep scoring` — you should see the engine plus **one** -> threshold config. +> **One bundle only.** The `appsec-bot-*` glob loads every installed threshold config and their +> rejection rules stack — the strictest wins silently. Verified: `-strict` alongside the default +> leaves both loaded, rejecting at 45. `cscli appsec-configs list | grep scoring` must show the +> engine plus **one** threshold. ```bash sudo cscli collections install crowdsecurity/appsec-bot-challenge ``` -| Env | Prefix | -|---|---| -| systemd | `sudo cscli …` | -| docker | `docker exec <name> cscli …` | -| k8s | `kubectl exec -n <ns> <pod> -- cscli …` | - -Docker: prefer baking it in with `-e COLLECTIONS="crowdsecurity/appsec-bot-challenge"` so it -survives a container replace. +Prefix: `sudo cscli …` / `docker exec <name> cscli …` / `kubectl exec -n <ns> <pod> -- cscli …`. +Docker: prefer `-e COLLECTIONS="crowdsecurity/appsec-bot-challenge"` so it survives a container +replace. ## 2 — Acquisition -`/etc/crowdsec/acquis.d/appsec.yaml`: - ```yaml +# /etc/crowdsec/acquis.d/appsec.yaml listen_addr: 127.0.0.1:7422 appsec_configs: - - crowdsecurity/appsec-default # your existing WAF config, if any + - crowdsecurity/appsec-default # existing WAF config, if any - crowdsecurity/appsec-bot-* labels: type: appsec source: appsec ``` -Glob patterns in `appsec_configs` are themselves version-gated (see SKILL.md § Step 1.6). They expand against **installed** -appsec-configs only, and a pattern matching nothing is fatal at startup: +Globs expand against **installed** appsec-configs only; matching nothing is fatal, so install +the collection first: ``` FATAL crowdsec init: while loading acquisition config: /etc/crowdsec/acquis.d/appsec.yaml: datasource of type appsec: unable to resolve appsec_config "crowdsecurity/appsec-bot-*": no installed appsec-config matches pattern "crowdsecurity/appsec-bot-*" ``` -So install the collection **before** writing the glob. Custom configs of your own are not -matched by the `crowdsecurity/` glob and must be listed by name — see -[./customize.md](./customize.md). - -Docker: mount the acquisition dir *and* a data volume (`/var/lib/crowdsec/data`), which the -entrypoint refuses to start without. Kubernetes: the acquisition goes in the log-processor -ConfigMap. - -Reload, then confirm: +Your own configs are not matched by the `crowdsecurity/` glob — list them by name. Docker: mount +the acquisition dir *and* a `/var/lib/crowdsec/data` volume (the entrypoint refuses to start +without it). k8s: acquisition goes in the log-processor ConfigMap. Then reload and confirm: ```bash sudo systemctl reload crowdsec @@ -90,66 +72,39 @@ sudo grep "WAF challenge runtime initialized" /var/log/crowdsec.log ## 3 — Wire the bouncer -Setting `APPSEC_URL` is not enough. Bot detection needs the bouncer to send **every** request -to AppSec, and to treat four internal paths as AppSec's, not the origin's. - -### nginx / OpenResty - -`/etc/crowdsec/bouncers/crowdsec-nginx-bouncer.conf`: - -```ini -APPSEC_URL=http://127.0.0.1:7422 -``` - -That is all bot detection needs — the bouncer consults AppSec for every IP that has no -decision, so clean visitors get challenged out of the box. - -**Leave `ALWAYS_SEND_TO_APPSEC` at its default (`false`).** It only governs IPs that are -*already banned*: with the default they get the ban page and AppSec is never consulted, which -is what you want. Setting it to `true` sends banned IPs to AppSec as well, and since the -bot-detection configs challenge everything, a `ban` decision is effectively downgraded to a -challenge. Verified on 1.2.2 — a banned IP returned the 403 ban page with the default and the -challenge page with `true`. - -Then `sudo nginx -t && sudo systemctl restart nginx`. Prefer `restart` over `reload` after -editing the bouncer config: the lua module reads it at init, and a graceful reload can keep -serving from old workers long enough to look like the change had no effect. - -### What has to survive between client and AppSec - -A supported bouncer handles the challenge protocol for you — picking one from -[./overview.md](./overview.md) § Bouncer support is the whole job. What you do own is -everything *else* in the request path: a CDN, reverse proxy or WAF in front of the bouncer can -silently break the challenge. Three things must reach their destination intact: - -- **`/crowdsec-internal/challenge/*`** (the JS assets and the `POST …/submit`) — these are - served by AppSec, not your origin. A proxy that routes, caches, or rewrites them breaks the - loop; symptoms in [./troubleshoot.md](./troubleshoot.md) § 3. -- **The `__crowdsec_challenge` cookie**, in both directions. Anything stripping `Set-Cookie` - or `Cookie` produces an endless challenge loop (§ 4). -- **The real client IP**, on every request including the assets and the submit — not just the - first page. See [../../configure/bouncers/web-servers.md](../../configure/bouncers/web-servers.md) - for per-bouncer real-IP configuration. - -Writing a bouncer that speaks the challenge protocol is out of scope here — the wire format, -status codes and cookie handling are specified in +nginx / OpenResty — `APPSEC_URL=http://127.0.0.1:7422` in +`/etc/crowdsec/bouncers/crowdsec-nginx-bouncer.conf` is all that is required; the bouncer +consults AppSec for every IP with no decision. + +> **Leave `ALWAYS_SEND_TO_APPSEC` at its default (`false`).** It only governs *already banned* +> IPs: the default serves them the ban page without consulting AppSec. `true` sends them to +> AppSec too, and since bot-detection configs challenge everything, a `ban` is effectively +> downgraded to a challenge. Verified on 1.2.2. + +`sudo nginx -t && sudo systemctl restart nginx` — prefer `restart` over `reload`, since the lua +module reads config at init and a graceful reload can keep old workers serving. + +A supported bouncer handles the protocol ([./overview.md](./overview.md) § Bouncer support), but +a CDN or reverse proxy in front of it can break the challenge. Three things must arrive intact: +`/crowdsec-internal/challenge/*` (JS assets, `POST …/submit`), served by AppSec and not your +origin — routing, caching or rewriting them breaks the loop +([./troubleshoot.md](./troubleshoot.md) § 3); the `__crowdsec_challenge` cookie in both +directions, since stripping `Set-Cookie` or `Cookie` causes an endless loop (§ 4); and the real +client IP on every request including assets and the submit +([../../configure/bouncers/web-servers.md](../../configure/bouncers/web-servers.md)). +Implementing the protocol in a new bouncer is out of scope — <https://docs.crowdsec.net/docs/next/appsec/bot_detection/challenge_protocol>. ## 4 — Smoke test -### a. Engine level — works with no bouncer at all - -Useful anywhere, and the only option if your bouncer has no bot-detection support yet. +### a. Engine level (no bouncer needed) ```bash KEY=$(sudo cscli bouncers add smoketest -o raw) q(){ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:7422/ \ - -H "x-crowdsec-appsec-api-key: $KEY" \ - -H "x-crowdsec-appsec-ip: $2" \ - -H "x-crowdsec-appsec-host: example.com" \ - -H "x-crowdsec-appsec-uri: $1" \ - -H "x-crowdsec-appsec-verb: GET" \ - -H "x-crowdsec-appsec-user-agent: $3"; } + -H "x-crowdsec-appsec-api-key: $KEY" -H "x-crowdsec-appsec-ip: $2" \ + -H "x-crowdsec-appsec-host: example.com" -H "x-crowdsec-appsec-uri: $1" \ + -H "x-crowdsec-appsec-verb: GET" -H "x-crowdsec-appsec-user-agent: $3"; } q / 1.2.3.4 "Mozilla/5.0" # 403 — challenged q /robots.txt 1.2.3.4 "Mozilla/5.0" # 200 — crawler-files exclusion @@ -159,35 +114,11 @@ q / 1.2.3.4 "Googlebot/2.1" # 403 — spoofed UA, no IP proof q / 66.249.66.1 "Googlebot/2.1" # 200 — real Googlebot, FCrDNS verified ``` -The last two are the useful pair: a bot claiming to be Googlebot from an arbitrary IP is -still challenged, while the real crawler is waved through. `403` here means "challenge -issued", not "banned" — see the `http_status` note in [./overview.md](./overview.md). - -To see the envelope itself: - -```bash -curl -s http://127.0.0.1:7422/ -H "x-crowdsec-appsec-api-key: $KEY" \ - -H "x-crowdsec-appsec-ip: 1.2.3.4" -H "x-crowdsec-appsec-host: example.com" \ - -H "x-crowdsec-appsec-uri: /" -H "x-crowdsec-appsec-verb: GET" \ - -H "x-crowdsec-appsec-user-agent: Mozilla/5.0" \ - | python3 -c 'import json,sys; d=json.load(sys.stdin); d["user_body_content"]=d["user_body_content"][:60]; print(json.dumps(d,indent=2))' -``` - -```json -{ - "action": "challenge", - "http_status": 200, - "user_body_content": "<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <title>CrowdSec", - "user_headers": { - "Cache-Control": ["no-cache, no-store"], - "Content-Security-Policy": ["default-src 'self'; script-src 'self' 'unsafe-inline'; …"], - "Content-Type": ["text/html"] - } -} -``` - -No `user_cookies` on the initial challenge — the cookie only appears on a solved submission -or a `GrantChallengeCookie()` grant. +The last pair is the useful one. `403` means "challenge issued", not "banned". Drop +`-o /dev/null -w …` to see the envelope: `action`, `http_status`, `user_body_content` (the +challenge HTML) and `user_headers` (`Content-Type: text/html`, `Cache-Control: no-cache, +no-store`, and the challenge's own `Content-Security-Policy`). No `user_cookies` on the initial +challenge — the cookie appears only on a solved submission or a `GrantChallengeCookie()` grant. ### b. Through the bouncer @@ -196,54 +127,31 @@ curl -s -o /tmp/c.html -w "%{http_code} %{size_download}\n" http://your-site/ grep -c "CrowdSec Challenge" /tmp/c.html ``` -Expect `200` with a body of a few hundred KB containing `CrowdSec Challenge` — the engine -returned 403 and the bouncer rendered it as a 200 page. +Expect `200` with a few hundred KB containing `CrowdSec Challenge`. ### c. Prove a bot gets caught -Point any CDP-driven browser at the site — Puppeteer, Playwright-Chromium, or -`chrome --remote-debugging-port`. The `cdp` signal alone scores 100, over every threshold. - -```js -// npm i puppeteer && node cdp-check.js -const puppeteer = require("puppeteer"); -(async () => { - const b = await puppeteer.launch({ headless: false }); - const p = await b.newPage(); - await p.goto("http://your-site/", { waitUntil: "networkidle0" }); - await b.close(); -})(); -``` - -Expected — the browser solves the PoW, submits, and is rejected on its fingerprint: +Point any CDP-driven browser at the site (Puppeteer, Playwright-Chromium, +`chrome --remote-debugging-port`). `cdp` alone scores 100, above every threshold. ``` -level=info msg="on_challenge_submit rejected" automation=true cpu_count=16 fsid=FS1_0000100… is_bot=true language=en-US reason="request score 100" signals="[cdp]" source=203.0.113.7 timezone=Europe/Paris ua="Mozilla/5.0 (X11; Linux x86_64) … Chrome/148.0.0.0 Safari/537.36" url="http://your-site/" +level=info msg="on_challenge_submit rejected" automation=true cpu_count=16 fsid=FS1_0000100… is_bot=true language=en-US reason="request score 100" signals="[cdp]" source=203.0.113.7 timezone=Europe/Paris ua="Mozilla/5.0 … Chrome/148.0.0.0 Safari/537.36" url="http://your-site/" level=info msg="WAF bot-detection: 203.0.113.7 rejected by crowdsecurity/rejected-browser-submission (request score 100)" ``` ```bash sudo cscli alerts list --kind bot-detection -sudo cscli alerts inspect <id> -d -``` - -``` -| bot_detected | true | -| challenge_event | rejected | -| fail_reason | request score 100 | -| request_score | 100 | -| score_reasons | cdp=100 | -| target_uri | / | +sudo cscli alerts inspect <id> -d # bot_detected, challenge_event=rejected, fail_reason, + # request_score, score_reasons=cdp=100 ``` -The alert shows `Remediation: false` — one rejection records the catch without banning. Keep -the bot running and the shipped scenarios ban it; verified here after eight consecutive -rejections: +One rejection has `Remediation: false` and does not ban. Keep the bot running and the shipped +scenarios ban it — verified after eight consecutive rejections: | Scenario | Fires on | |---|---| -| `crowdsecurity/appsec-bot-challenge-too-many-requests` | Served the challenge 10×/20s without ever submitting. Cancelled as soon as a submission arrives, so a real browser never trips it. | -| `crowdsecurity/appsec-bot-challenge-too-many-submissions` | 5 submissions/20s. Rejected submissions count too, so this is what bans a bot that keeps failing the fingerprint check. A real browser solves once, takes its cookie and stops submitting. | +| `…-too-many-requests` | Challenge served 10×/20s with no submission. Cancelled once a submission arrives, so a real browser never trips it. | +| `…-too-many-submissions` | 5 submissions/20s. Rejected submissions count, so this bans a bot failing the fingerprint check. A real browser solves once, takes its cookie and stops submitting. | ### d. Metrics @@ -256,7 +164,5 @@ sudo cscli metrics show bot-detection | 127.0.0.1:7422/ | 7 | 1 | - | - | 7 | - | 1 | - | ``` -Detail in [./troubleshoot.md](./troubleshoot.md) § 7. - -Next: [./configure.md](./configure.md) for multi-instance and cookie tuning, -[./customize.md](./customize.md) to change who gets challenged. +Column meanings: [./troubleshoot.md](./troubleshoot.md) § 7. Next: +[./configure.md](./configure.md), [./customize.md](./customize.md). diff --git a/skills/crowdsec/references/appsec/bot-detection/overview.md b/skills/crowdsec/references/appsec/bot-detection/overview.md index eb13446..1da53f5 100644 --- a/skills/crowdsec/references/appsec/bot-detection/overview.md +++ b/skills/crowdsec/references/appsec/bot-detection/overview.md @@ -10,67 +10,44 @@ verified: notes: "challenge envelope + exclusions only" --- -# Bot detection (AppSec challenge mode) — what it is +# Bot detection (AppSec challenge mode) -Canonical docs: <https://docs.crowdsec.net/docs/next/appsec/bot_detection/intro> · +Canonical: <https://docs.crowdsec.net/docs/next/appsec/bot_detection/intro> · [how it works](https://docs.crowdsec.net/docs/next/appsec/bot_detection/how_it_works) · -[challenge protocol](https://docs.crowdsec.net/docs/next/appsec/bot_detection/challenge_protocol) +[protocol](https://docs.crowdsec.net/docs/next/appsec/bot_detection/challenge_protocol) -Version and prerequisites: SKILL.md § Step 1.6 — Feature compatibility. +Version and prerequisites: SKILL.md § Step 1.6. **Alpha upstream** — config keys, expr helpers +and shipped hub rules may change between releases. -**Marked alpha upstream** — config keys, expr helpers and the shipped hub rules may change -between releases. Say so before someone builds a rollout on it. +## Mechanism -## Not a captcha +The engine serves an HTML page running a **proof-of-work** plus a **device fingerprint** +(`fpscanner`). The browser POSTs the result, `on_challenge_submit` hooks score it, and a sealed +`__crowdsec_challenge` cookie is issued on success. Not a captcha: no third-party provider, no +user interaction; it proves *browser authenticity*, not humanity. Headless Chromium, Selenium, +Playwright and CDP-driven browsers solve the PoW — they are caught by the fingerprint. -| | Captcha | Bot detection (challenge) | -|---|---|---| -| Who renders it | The bouncer, from its own template | The **engine**, served through the bouncer | -| Third-party provider | hCaptcha / reCAPTCHA / Turnstile keys required | None | -| User interaction | Yes — solve a puzzle | None — the page self-solves | -| What it proves | A human clicked | The client is a real browser (runs JS, has a plausible device fingerprint) | - -The engine serves a small HTML page that runs a **proof-of-work** in a worker and collects a -**device fingerprint** (the open-source `fpscanner` library). The browser POSTs the result -back; hooks score the fingerprint; a sealed `__crowdsec_challenge` cookie is issued on -success. Headless Chromium, Selenium, Playwright and CDP-driven browsers solve the PoW fine -— they get caught by the *fingerprint*, not the puzzle. - -`challenge` is a fourth AppSec action next to `allow` / `ban` / `captcha`. Restrictiveness -ordering used by remediation components: `allow < unknown < captcha < challenge < ban`. - -It is **not** a LAPI decision type — it cannot be set from `profiles.yaml`, only from an -appsec-config. See [../../configure/profiles.md](../../configure/profiles.md) § Decision types. +`challenge` is a fourth AppSec action beside `allow` / `ban` / `captcha`. Restrictiveness +ordering: `allow < unknown < captcha < challenge < ban`. Not a LAPI decision type — settable +only from an appsec-config, never `profiles.yaml`. ## Request flow -``` - ┌─────────── first visit ───────────┐ -client ──▶ bouncer ──▶ AppSec :7422 ──▶ in-band WAF rules - └──▶ post_eval: SendChallenge() - │ - 403 to the bouncer + JSON envelope ◀─┘ - {action:"challenge", http_status:200, user_body_content:"<html>…", - user_headers:{…}} ← no cookie yet - │ - bouncer serves the body to the client with http_status - │ - browser runs PoW + fingerprint, POSTs /crowdsec-internal/challenge/submit - │ - AppSec validates crypto/PoW ──▶ on_challenge_submit hooks score it - │ - {"status":"ok"} + Set-Cookie __crowdsec_challenge → allowed - {"status":"failed"} crypto/PoW invalid - {"status":"rejected"} a hook called RejectSubmission() → alert - │ - ┌─── later visits carrying a valid cookie ───┐ - AppSec runs on_challenge hooks (before pre_eval), fingerprint available -``` - -The outer HTTP status to the *bouncer* is always **403** for any non-allow action. The status -the *client* sees is `http_status` inside the envelope — `200` for a challenge page, `307` -for a `GrantChallengeCookie()` redirect. Confusing the two is the most common misreading of -a `curl` against `:7422`. +1. Bouncer → AppSec `:7422` → in-band WAF rules → `post_eval: SendChallenge()`. +2. AppSec returns **403** to the bouncer with + `{action:"challenge", http_status:200, user_body_content:"<html>…", user_headers:{…}}` — no + cookie yet. +3. Bouncer serves that body to the client using `http_status`. +4. Browser runs PoW + fingerprint, POSTs `/crowdsec-internal/challenge/submit`. +5. AppSec validates crypto/PoW, runs `on_challenge_submit` hooks, answers `{"status":"ok"}` + + `Set-Cookie` (allowed), `{"status":"failed"}` (crypto/PoW invalid), or + `{"status":"rejected"}` (a hook called `RejectSubmission()` → alert). +6. Later requests with a valid cookie run `on_challenge` hooks (before `pre_eval`), with + `fingerprint` populated. + +The status to the *bouncer* is always **403** for any non-allow action; the status the *client* +sees is `http_status` in the envelope — `200` challenge page, `307` `GrantChallengeCookie()` +redirect. ## Terminology @@ -78,18 +55,16 @@ Extends [../overview.md](../overview.md) § Terminology. | Term | Meaning | |---|---| -| Challenge | The PoW + fingerprint page the engine serves | -| Fingerprint / FSID | The device fingerprint and its stable id, visible in alerts as `fingerprint_id` | +| Fingerprint / FSID | Device fingerprint and stable id; in alerts as `fingerprint_id` | | Difficulty | PoW cost: `disabled` / `low` / `medium` (default) / `high` / `impossible` | -| Exemption | Request skipped the challenge entirely (`ExemptFromChallenge`) — per request, no cookie | -| Granted cookie | Challenge waived *and* a cookie issued (`GrantChallengeCookie`) — persists | -| Master secret / epoch | Root key all per-epoch signing keys derive from; must be shared across instances | -| Score | Points accumulated from fingerprint mismatch signals; a threshold config rejects above it | +| Exemption | Challenge skipped (`ExemptFromChallenge`) — per request, no cookie | +| Granted cookie | Challenge waived *and* cookie issued (`GrantChallengeCookie`) — persists | +| Master secret / epoch | Root key for per-epoch signing keys; shared across instances | +| Score | Points from fingerprint mismatch signals; a threshold config rejects above it | ## Event vocabulary -Challenge events reach the pipeline as a **separate source** from WAF rule matches, so -challenge scenarios never collide with WAF scenarios: +Challenge events use a separate source from WAF rule matches, so scenarios never collide: | | WAF rule match | Challenge | |---|---|---| @@ -97,32 +72,19 @@ challenge scenarios never collide with WAF scenarios: | Parser | `crowdsecurity/appsec-logs` | `crowdsecurity/appsec-bot-detection-logs` | | `evt.Meta.log_type` | `appsec` | `appsec-challenge` | -`evt.Meta.challenge_event` / `evt.Parsed.challenge_event` takes five values: - -| Value | Meaning | -|---|---| -| `requested` | Challenge page served | -| `submitted` | A submission arrived | -| `failed` | Submission failed crypto / PoW validation | -| `rejected` | Submission decoded fine but a hook called `RejectSubmission()` — **this is the "caught a bot" signal** | -| `solved` | Validated, cookie issued | - -A rejection raises an alert with kind **`bot-detection`** and reason -`crowdsecurity/rejected-browser-submission`, carrying `Remediation: false` — **the alert -alone never bans**. Only the two shipped leaky scenarios turn repeated abuse into a decision. +`challenge_event` (on both `evt.Meta` and `evt.Parsed`): `requested` (page served), +`submitted`, `failed` (crypto/PoW invalid), `rejected` (`RejectSubmission()` — the "caught a +bot" signal), `solved` (cookie issued). A rejection raises a `bot-detection` alert, reason +`crowdsecurity/rejected-browser-submission`, `Remediation: false` — one rejection never bans; +repeated ones do via the shipped scenarios ([./deploy.md](./deploy.md) § 4c). ## Prerequisites -1. A working AppSec setup — [../deploy.md](../deploy.md) first. -2. A **bouncer that supports bot detection** (see below). Enabling it behind one that does - not will refuse clients, not challenge them. -3. A host that can run the challenge runtime: obfuscating the challenge JS needs a - WebAssembly runtime in compiler mode — `arm64`, or `amd64` **with SSE4.1** — and the - engine must be allowed to map executable memory. W^X hardening, a seccomp profile or an - SELinux policy can deny it, and the AppSec datasource then fails to load and CrowdSec does - not start. See [./troubleshoot.md](./troubleshoot.md) § 1. -4. Clients that run JavaScript and accept cookies. API clients, feed readers and CLI tools - cannot — exclude them ([./customize.md](./customize.md) § Let legitimate traffic through). +Working AppSec ([../deploy.md](../deploy.md)); a bouncer supporting bot detection (behind one +that does not, clients are refused rather than challenged); WASM compiler mode — `arm64`, or +`amd64` **with SSE4.1**, plus permission to map executable memory, else startup fails +([./troubleshoot.md](./troubleshoot.md) § 1); clients that run JS and accept cookies (API +clients, feed readers and CLI tools cannot — exempt them via [./customize.md](./customize.md)). Startup confirmation, all environments: @@ -132,18 +94,10 @@ level=info msg="WAF challenge runtime initialized" cookie_ttl=12h0m0s crypto_poo ## Bouncer support -| Bouncer | Bot detection | Verified here | -|---|---|---| -| nginx (`crowdsec-nginx-bouncer`) | ✅ | ✅ 1.2.2 | -| OpenResty | ✅ | — | -| HAProxy **SPOA** (not the Lua bouncer) | ✅ | — | -| Traefik | ✅ | — | -| Envoy | ✅ | — | -| apache, cloudflare, aws-waf, fastly, firewall, blocklist-mirror, php/wordpress | ❌ | — | - -**Upstream documents no minimum version for any of them.** The only version this skill has -observed working is `crowdsec-nginx-bouncer` **1.2.2** against engine **1.8.0**. For anything -else, check the bouncer ships `challenge.lua` (lua-based ones) or carries the "Bot Detection" -badge on its docs page, and test before rolling out. - -Next: [./deploy.md](./deploy.md) to turn it on. +Supported: **nginx**, **OpenResty**, **HAProxy SPOA** (not the Lua bouncer), **Traefik**, +**Envoy**. Not supported: apache, cloudflare, aws-waf, fastly, firewall, blocklist-mirror, +php/wordpress. Upstream publishes **no minimum version**; verified here +`crowdsec-nginx-bouncer` 1.2.2 against engine 1.8.0. Otherwise check the bouncer ships +`challenge.lua` (lua-based) or carries the "Bot Detection" badge, and test before rollout. + +Next: [./deploy.md](./deploy.md). diff --git a/skills/crowdsec/references/appsec/bot-detection/troubleshoot.md b/skills/crowdsec/references/appsec/bot-detection/troubleshoot.md index 1110405..4d78c75 100644 --- a/skills/crowdsec/references/appsec/bot-detection/troubleshoot.md +++ b/skills/crowdsec/references/appsec/bot-detection/troubleshoot.md @@ -8,240 +8,156 @@ verified: # Bot detection — troubleshooting -Run [`scripts/diagnose.sh`](../../../scripts/diagnose.sh) first for the general picture, then -come here. For AppSec problems that are not challenge-specific, use +Run [`scripts/diagnose.sh`](../../../scripts/diagnose.sh) first. Non-challenge AppSec problems: [../troubleshoot.md](../troubleshoot.md). -Quick orientation — where in the funnel does it break? +Locate the break with `sudo cscli metrics show bot-detection`: -```bash -sudo cscli metrics show bot-detection -``` - -| Column stuck at zero | Look at | +| Stuck at zero | See | |---|---| -| `Requested` | § 2 — no challenge is being issued at all | -| `Submitted` (but `Requested` climbing) | § 3 — the client never posts back | -| `Solved` (but `Submitted` climbing) | § 4 / § 6 — everything is being rejected | -| `Exempt` unexpectedly high | § 5 — an exclusion is too broad | +| `Requested` | § 2 — no challenge issued | +| `Submitted` while `Requested` climbs | § 3 — client never posts back | +| `Solved` while `Submitted` climbs | § 4 / § 6 — everything rejected | +| `Exempt` unexpectedly high | § 5 — exclusion too broad | -## 1 — CrowdSec won't start after enabling it - -``` -FATAL … failed to create wasm runtime in compiler mode -FATAL … wasm compiler mode unavailable -``` +## 1 — Won't start after enabling -The challenge JS is obfuscated through a WebAssembly runtime in compiler mode. It needs -`arm64`, or `amd64` **with SSE4.1**, and permission to map executable memory. Check in order: +`FATAL … failed to create wasm runtime in compiler mode` / `FATAL … wasm compiler mode +unavailable`. Needs `arm64`, or `amd64` **with SSE4.1**, plus permission to map executable +memory. Causes: old/emulated CPU, W^X kernel hardening, restrictive seccomp (docker +`--security-opt seccomp=…`), SELinux denying `execmem`. No software fallback. ```bash -grep -o sse4_1 /proc/cpuinfo | head -1 # must print sse4_1 on amd64 +grep -o sse4_1 /proc/cpuinfo | head -1 # must print on amd64 sudo journalctl -u crowdsec -n 50 --no-pager | grep -i wasm sudo ausearch -m avc -ts recent 2>/dev/null | tail # SELinux denials ``` -Common causes: an old/emulated CPU, a hardened kernel with W^X enforcement, a restrictive -seccomp profile (docker `--security-opt seccomp=…`), or an SELinux policy denying -`execmem`. There is no software fallback — fix the host or run bot detection elsewhere. - -Different fatal, same moment: - -``` -FATAL … unable to resolve appsec_config "crowdsecurity/appsec-bot-*": no installed appsec-config matches pattern "crowdsecurity/appsec-bot-*" -``` - -The glob is in the acquisition but the collection was never installed. Install it, then -reload — [./deploy.md](./deploy.md) § 1. - -Hook placement errors, both fatal at startup: +Other startup fatals: | Error | Fix | |---|---| -| `unable to compile apply SendChallenge() : unknown name SendChallenge` | `SendChallenge()` is not available in `pre_eval`. Move it to `post_eval` or `on_challenge`. | -| `on_challenge hooks are only valid in-band, not under outofband` | Move the hook under `inband:`. | - -## 2 — No challenge is ever served - -`Requested` stays at zero and pages load normally. - -1. **Is `APPSEC_URL` set, and did the change actually take effect?** - ```bash - grep -E "^APPSEC_URL" /etc/crowdsec/bouncers/crowdsec-nginx-bouncer.conf - ``` - Empty means the WAF is off entirely. If it is set and nothing changed, `systemctl restart - nginx` rather than `reload` — the lua module reads the config at init and a graceful - reload can keep old workers serving, which looks exactly like the setting being ignored. - (`ALWAYS_SEND_TO_APPSEC` is **not** the answer here; it only affects already-banned IPs. - See [./deploy.md](./deploy.md) § 3.) -2. **Is the config loaded?** `cscli appsec-configs list` should show the - `appsec-bot-challenge-*` entries. If not, the acquisition does not reference them. -3. **Test the engine directly**, bypassing the bouncer entirely — - [./deploy.md](./deploy.md) § 4a. If curl gets a `challenge` envelope but the browser does - not, the problem is the bouncer, not the engine. -4. **Is something exempting everything?** Check the exempt-by-reason table (§ 5). - -## 3 — Challenge served, but nothing ever submits - -`Requested` climbs, `Submitted` stays at zero. - -The bouncer is serving the page but not forwarding the challenge's own traffic back to -AppSec. All four internal paths must reach AppSec unmodified, never the origin: - -``` -GET /crowdsec-internal/challenge/challenge.js -GET /crowdsec-internal/challenge/fpscanner.js -GET /crowdsec-internal/challenge/pow-worker.js -POST /crowdsec-internal/challenge/submit -``` - -```bash -curl -s -o /dev/null -w "%{http_code}\n" http://your-site/crowdsec-internal/challenge/pow-worker.js -``` - -A `404` from your origin means the bouncer is not intercepting them. Check the browser -console too — a Content-Security-Policy on your side that blocks `blob:` workers or inline -script stops the PoW from ever running; the challenge ships its own CSP, and a stricter one -added by the origin or a proxy overrides it. - -Also plausible: the client genuinely cannot solve it. API clients, feed readers, curl and -mobile apps do not run JS. Exempt them by path — -[./customize.md](./customize.md) § Let legitimate traffic through. +| `unable to resolve appsec_config "crowdsecurity/appsec-bot-*": no installed appsec-config matches pattern` | Collection not installed — [./deploy.md](./deploy.md) § 1 | +| `unable to compile apply SendChallenge() : unknown name SendChallenge` | Unavailable in `pre_eval`; move to `post_eval` / `on_challenge` | +| `on_challenge hooks are only valid in-band, not under outofband` | Move the hook under `inband:` | + +## 2 — No challenge ever served + +`Requested` at zero, pages load normally. + +1. `grep -E "^APPSEC_URL" /etc/crowdsec/bouncers/crowdsec-nginx-bouncer.conf` — empty means the + WAF is off. If set but nothing changed, `systemctl restart nginx` rather than `reload`: the + lua module reads config at init and a graceful reload can keep old workers serving. + `ALWAYS_SEND_TO_APPSEC` is **not** the answer — it only affects already-banned IPs + ([./deploy.md](./deploy.md) § 3). +2. `cscli appsec-configs list` must show the `appsec-bot-challenge-*` entries. +3. Test the engine directly ([./deploy.md](./deploy.md) § 4a). Envelope from curl but nothing in + the browser ⇒ the bouncer, not the engine. +4. Check the exempt-by-reason table (§ 5) for an over-broad exclusion. + +## 3 — Challenge served, nothing submits + +`Requested` climbs, `Submitted` at zero. These must reach AppSec unmodified, never the origin: +`GET /crowdsec-internal/challenge/{challenge.js,fpscanner.js,pow-worker.js}` and +`POST /crowdsec-internal/challenge/submit`. A `404` from your origin on +`curl http://your-site/crowdsec-internal/challenge/pow-worker.js` means the bouncer is not +intercepting them. Also check the browser console: a CSP from your origin or a proxy blocking +`blob:` workers or inline script stops the PoW — the challenge ships its own CSP and a stricter +one overrides it. Also plausible: the client cannot run JS (API clients, feed readers, curl, +mobile apps) — exempt by path ([./customize.md](./customize.md)). ## 4 — Endless challenge loop -The browser solves the challenge, gets a cookie, and is challenged again on the next request. -The cookie is not surviving the round trip. +The cookie is not surviving the round trip. `Cookies Invalid` counts clients presenting a +cookie the runtime will not open. | Cause | Check | |---|---| -| Several AppSec instances with no shared `master_secret` | The startup warning below; fix per [./configure.md](./configure.md) § Multi-instance | -| Engine restarted with an ephemeral secret | Same warning — all outstanding cookies died with the process | -| A proxy stripping `Set-Cookie` or `Cookie` | Compare what the bouncer emits with what the browser stores | -| Bouncer collapsing multiple `user_cookies` into one header | Each entry must be its own `Set-Cookie` | -| Clock skew beyond `(max_live_epochs + 1) × key_rotation_interval` | `timedatectl` on every node | +| Several instances with no shared `master_secret` | Warning below — [./configure.md](./configure.md) § Multi-instance | +| Engine restarted with an ephemeral secret | Same warning; outstanding cookies died with the process | +| Proxy stripping `Set-Cookie` / `Cookie` | Compare bouncer output with browser storage | +| Bouncer collapsing `user_cookies` into one header | Each entry needs its own `Set-Cookie` | +| Clock skew past `(max_live_epochs + 1) × key_rotation_interval` | `timedatectl` on every node | ``` level=warning msg="no master secret configured for the WAF challenge runtime; generated an ephemeral random secret. Distributed (multi-WAF) deployments MUST configure a shared master_secret in the appsec config; single-instance deployments will see outstanding challenge cookies invalidated on restart." module=challenge ``` -`Cookies Invalid` in the metrics table counts exactly this — clients presenting a cookie the -runtime will not open. - -## 5 — Legitimate clients are being challenged or blocked - -Read the exempt table first; it tells you which exclusions are firing and which are not: - -```bash -sudo cscli metrics show bot-detection -``` - -``` -| Bot Detection — Exempted | -| Appsec Engine | Reason | Count | -| 127.0.0.1:7422/ | api | 1 | -| 127.0.0.1:7422/ | crawler-files | 2 | -| 127.0.0.1:7422/ | googlebot | 1 | -``` +## 5 — Legitimate clients challenged or blocked -**A real crawler is being challenged.** `MatchKnownBot` requires an identity proof, not just -a user-agent — exact IP, CIDR range, or forward-confirmed reverse DNS. So: +The exempt table (`Bot Detection — Exempted`, one row per reason) shows which exclusions fire. -- Confirm rDNS actually resolves from the engine host: `dig -x <crawler-ip> +short`, then - forward-resolve the name back. If either leg fails, the match fails closed. -- Check the engine's global `dns_cache` config (`crowdsec_service` → `dns_cache`) is not - disabled. -- A definition with only `user_agent` is rejected at load time by design. -- If it is **your own** datafile: it must be declared in a `data:` block. A file dropped into - `legit_bots/` by hand is never registered and `MatchKnownBot` silently returns false — - [./customize.md](./customize.md) § A crawler of your own. +**Real crawler challenged.** `MatchKnownBot` needs an identity proof, not a user-agent: +`dig -x <crawler-ip> +short` then forward-resolve the name — either leg failing means the match +fails closed; global `dns_cache` (`crowdsec_service`) must not be disabled; UA-only definitions +are rejected at load by design; your own datafile must be declared in a `data:` block, since a +file dropped into `legit_bots/` by hand is never registered +([./customize.md](./customize.md) § Your own crawler). -**A human is being challenged repeatedly** — that is § 4, not this section. +**Everything blocked, not challenged.** The bouncer does not support bot detection and falls +back to `ban` — [./overview.md](./overview.md) § Bouncer support. -**Everything is blocked, not challenged.** The bouncer does not support bot detection and is -falling back to `ban`. Check it against [./overview.md](./overview.md) § Bouncer support. +**A human challenged repeatedly** is § 4. -## 6 — Too many, or too few, rejections +## 6 — Too many / too few rejections ```bash sudo cscli appsec-configs list | grep scoring ``` -You should see the scoring engine plus **exactly one** threshold config. Two threshold -configs means two bundles are installed and the strictest one silently wins — -[./deploy.md](./deploy.md) § 1. +Expect the scoring engine plus **exactly one** threshold config. Two means two bundles are +installed and the strictest wins silently ([./deploy.md](./deploy.md) § 1). -The reject table doubles as a score histogram: each distinct `RejectSubmission` reason string -appears with its count, and the shipped threshold configs put the score in the reason -(`request score 115`). If real users cluster just above your threshold, move to the -permissive bundle or re-weight the offending signal — -[./customize.md](./customize.md) § Catch bots the shipped scoring misses. +The reject table doubles as a score histogram — each `RejectSubmission` reason with its count, +and shipped thresholds put the score in the reason (`request score 115`). If real users cluster +just above your threshold, move to the permissive bundle or re-weight the signal +([./customize.md](./customize.md) § Catch bots the scoring misses). -Remember what a single rejection does: it raises a `bot-detection` alert with -`Remediation: false`, which does not ban by itself. Repeated rejections do ban, through -`crowdsecurity/appsec-bot-challenge-too-many-submissions` — every rejected submission is -still a `submitted` event, so the bucket fills (capacity 5, leakspeed 20s). If a bot is -being rejected but never banned, it is retrying too slowly to overflow that bucket; add a -scenario on `challenge_event == 'rejected'` to react sooner -([./customize.md](./customize.md) § Custom scenarios on challenge events). +A single rejection raises a `bot-detection` alert with `Remediation: false` and does not ban. +Repeated ones ban via `appsec-bot-challenge-too-many-submissions` (capacity 5, leakspeed 20s), +because every rejected submission is still a `submitted` event. A bot rejected but never banned +is retrying too slowly to overflow that bucket — add a scenario on +`challenge_event == 'rejected'` ([./customize.md](./customize.md) § Custom scenarios). ## 7 — Reading the metrics -`cscli metrics show bot-detection` renders two sections (`appsec-challenge` and -`appsec-challenge-infra`, listed by `cscli metrics list`): - -``` -| Bot Detection | Requested | Submitted | Solved | Granted | Exempt | Protocol Failures | Submissions Rejected | Cookies Invalid | -| 127.0.0.1:7422/ | 7 | 1 | - | - | 7 | - | 1 | - | -``` +`cscli metrics show bot-detection` renders `appsec-challenge` and `appsec-challenge-infra`. | Column | Meaning | |---|---| | `Requested` / `Submitted` | Pages served / submissions received | -| `Solved` | Passed validation and got a cookie | -| `Granted` | Cookie issued by `GrantChallengeCookie()`, no challenge solved | -| `Exempt` | Skipped by `ExemptFromChallenge()` — broken out by reason below | -| `Protocol Failures` | Crypto or PoW validation failed — malformed or forged submissions | -| `Submissions Rejected` | Validation passed, a hook called `RejectSubmission()` — real detections | -| `Cookies Invalid` | Incoming cookie could not be opened (see § 4) | +| `Solved` | Passed validation, cookie issued | +| `Granted` | Cookie from `GrantChallengeCookie()`, no challenge solved | +| `Exempt` | Skipped by `ExemptFromChallenge()`, broken out by reason | +| `Protocol Failures` | Crypto/PoW validation failed — malformed or forged submissions | +| `Submissions Rejected` | Validation passed, a hook rejected — real detections | +| `Cookies Invalid` | Incoming cookie could not be opened (§ 4) | Infrastructure counters (signing key regenerated/evicted, re-obfuscation, dynamic module -evicted) are housekeeping and process-global. They should tick slowly and steadily; a -regeneration rate far above `key_rotation_interval` means something is restarting the runtime. - -`cscli metrics show appsec-engine` also gains `Ch. Requested` / `Ch. Accepted` / -`Ch. Rejected` columns. +evicted) are process-global housekeeping; a regeneration rate far above `key_rotation_interval` +means something is restarting the runtime. -Prometheus: `cs_appsec_challenge_requested_total`, `…_submitted_total`, -`…_accepted_total` (label `kind` = `solved` | `granted`), `…_rejected_total` (`kind` = -`protocol` | `submission` | `cookie`), `cs_appsec_challenge_exempt_total` (label `reason`), -and `cs_appsec_fingerprint_mismatch_total` (labels `reason`, `severity`). +`cscli metrics show appsec-engine` gains `Ch. Requested` / `Ch. Accepted` / `Ch. Rejected`. -## 8 — Turning up the logs +Prometheus: `cs_appsec_challenge_requested_total`, `…_submitted_total`, `…_accepted_total` +(`kind` = `solved` | `granted`), `…_rejected_total` (`kind` = `protocol` | `submission` | +`cookie`), `cs_appsec_challenge_exempt_total` (label `reason`), +`cs_appsec_fingerprint_mismatch_total` (labels `reason`, `severity`). -Raise verbosity for the challenge runtime alone, without making the rest of CrowdSec noisy — -add to your overlay appsec-config: +## 8 — Verbose logs -```yaml -challenge: - log_level: debug -``` - -```bash -sudo tail -F /var/log/crowdsec.log | grep -E "challenge submission|on_challenge_submit|module=challenge" -``` - -Line shapes to expect: +`challenge: { log_level: debug }` in your overlay raises verbosity for the challenge runtime +only. `sudo tail -F /var/log/crowdsec.log | grep -E "challenge submission|on_challenge_submit"`: ``` level=info msg="challenge submission accepted" source=198.51.100.42 fsid=FS1_… is_bot=false allowlisted=false -level=info msg="on_challenge_submit rejected" automation=true cpu_count=16 fsid=FS1_… is_bot=true language=en-US reason="request score 100" signals="[cdp]" source=203.0.113.7 timezone=Europe/Paris ua="Mozilla/5.0 …" url="http://your-site/" +level=info msg="on_challenge_submit rejected" automation=true cpu_count=16 fsid=FS1_… is_bot=true reason="request score 100" signals="[cdp]" source=203.0.113.7 timezone=Europe/Paris ua="Mozilla/5.0 …" url="http://your-site/" ``` `RejectSubmission("reason", "verbose")` adds `nonce`, `fp_time`, `memory`, `platform`, -`request_uuid` and the full signal list to that line. +`request_uuid` and the full signal list. -## 9 — Capturing a fingerprint to inspect +## 9 — Capture a fingerprint ```yaml on_challenge_submit: @@ -251,18 +167,12 @@ level=info msg="on_challenge_submit rejected" automation=true cpu_count=16 fsid= ``` Writes JSONL to `<datadir>/fingerprint_dumps/crowdsec_fp_dump_suspected-automation.jsonl` -with the fingerprint plus client IP, UA, host, URI and timestamp. - -> Load this config **before** the threshold config. `RejectSubmission()` is terminal — it -> halts every remaining `on_challenge_submit` rule, so a dump rule loaded after it never runs -> on the requests you most want to inspect. +(fingerprint plus client IP, UA, host, URI, timestamp). -## 10 — Console shows the alert but not the detail +> Load this **before** the threshold config — `RejectSubmission()` halts remaining +> `on_challenge_submit` rules, so a dump rule after it never runs on the requests you want. -The context (`score_reasons`, `fingerprint_id`, `request_score`) rides on the alert but is -hidden by default. Enable the context column or Comfort view in the Console alerts settings. -Locally the same data is always available: +## 10 — Console shows the alert without detail -```bash -sudo cscli alerts inspect <id> -d -``` +Context (`score_reasons`, `fingerprint_id`, `request_score`) is hidden by default — enable the +context column or Comfort view. Locally: `sudo cscli alerts inspect <id> -d`. diff --git a/skills/crowdsec/references/configure/profiles.md b/skills/crowdsec/references/configure/profiles.md index 964eced..fa21f82 100644 --- a/skills/crowdsec/references/configure/profiles.md +++ b/skills/crowdsec/references/configure/profiles.md @@ -77,10 +77,6 @@ on_success: break | `captcha` | Serve a challenge | Only **web-server / AppSec bouncers** can render captcha; a firewall bouncer can't and treats it as no-op. Needs captcha provider config. | | `throttle` | Rate-limit | Bouncer-dependent support. | -`challenge` (bot detection) is **not** a decision type and cannot be set here — it is an -AppSec action decided per request by an appsec-config. See -[../appsec/bot-detection/overview.md](../appsec/bot-detection/overview.md). - ### Escalation with `duration_expr` Longer bans for repeat offenders — uncomment in the default profile: From f279cb0b0cf297c472528a9c9a16f7ec31923380 Mon Sep 17 00:00:00 2001 From: "Thibault \"bui\" Koechlin" <orixxx@gmail.com> Date: Tue, 1 Sep 2026 10:35:52 +0200 Subject: [PATCH 4/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .claude-plugin/marketplace.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c61d428..a009ce3 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ { "name": "crowdsec", "source": "./", - "description": "CrowdSec skills: an operational skill (engine, cscli, hub, bouncers, WAF/AppSec across bare-metal, Docker, Kubernetes) and a Service API skill (premium Console cloud API \u2014 blocklists, allowlists, firewall integrations, metrics, decisions).", + "description": "CrowdSec skills: an operational skill (engine, cscli, hub, bouncers, WAF/AppSec and bot detection across bare-metal, Docker, Kubernetes) and a Service API skill (premium Console cloud API \u2014 blocklists, allowlists, firewall integrations, metrics, decisions).", "version": "0.3.0", "author": { "name": "CrowdSec",