diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 4c0a59c..a009ce3 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 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",
"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..30c8a95 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,22 @@ 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.
+
## [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..ccb6791
--- /dev/null
+++ b/skills/crowdsec/references/appsec/bot-detection/configure.md
@@ -0,0 +1,88 @@
+---
+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"
+---
+
+# Challenge runtime configuration
+
+Canonical:
+
+`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
+name: mycorp/appsec-bot-challenge-overlay
+
+challenge:
+ master_secret: "<64+ hex chars; generate with: openssl rand -hex 32>"
+ cookie_ttl: 2h
+```
+
+| Key | Default | Purpose |
+|---|---|---|
+| `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. |
+
+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
+
+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.
+
+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 |
+|---|---|
+| `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.
+
+## Difficulty
+
+PoW cost in leading zero bits, calibrated for a low-end 2-core phone; desktops are ~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 — hard block, rejected server-side, no reason leaked | |
+
+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 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)"
+```
+
+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
new file mode 100644
index 0000000..d076fe1
--- /dev/null
+++ b/skills/crowdsec/references/appsec/bot-detection/customize.md
@@ -0,0 +1,214 @@
+---
+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, custom scenario on both evt.Meta and evt.Parsed paths"
+---
+
+# Custom bot-detection configs
+
+Canonical: ·
+[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 via
+your own appsec-config — never by editing hub files (`cscli hub upgrade` overwrites them).
+
+## Deploy loop
+
+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
+
+| 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 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` |
+
+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:
+
+| 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
+
+| You can prove | Use | Scope |
+|---|---|---|
+| 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 |
+
+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-overlay
+inband:
+ pre_eval:
+ # Exempt everything except the funnel you care about.
+ - filter: '!(req.URL.Path startsWith "/checkout/")'
+ apply:
+ - ExemptFromChallenge("outside-checkout")
+ # 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")
+ # 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") # omit TTL to use cookie_ttl
+```
+
+`GrantChallengeCookie` answers `307` with `Location` and
+`__crowdsec_challenge=…; Path=/; Max-Age=86399; HttpOnly; SameSite=Lax`.
+
+### Your own crawler
+
+`MatchKnownBot` matches
+`(user_agent matches AND at least one path matches) AND (exact IP OR CIDR range OR FCrDNS)`
+against newline-delimited JSON 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` 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.** 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
+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
+```
+
+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).
+
+## Catch bots the scoring misses
+
+`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 — 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**:
+
+| 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 |
+
+**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
+inband:
+ on_challenge_submit:
+ # Re-weight: load between the scoring and threshold configs. Scores may be negative.
+ - filter: EvaluateMismatches().Has("utc_timezone")
+ apply:
+ - AddRequestScore(45, "utc_timezone_mycorp")
+ - filter: EvaluateMismatches().Has("ua_mobile")
+ apply:
+ - AddRequestScore(-15, "ua_mobile_expected")
+ # 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. Optional 2nd arg "minimal" / "info"
+ # (default) / "verbose" controls logged fingerprint detail.
+ - filter: EvaluateMismatches().High() >= 1
+ apply:
+ - 'RejectSubmission("high-severity mismatch")'
+ on_challenge:
+ # Escalate instead of rejecting.
+ - filter: EvaluateMismatches().High() >= 1
+ apply:
+ - SetChallengeDifficulty("high")
+ - SendChallenge()
+ # Drop a cookie-bearing client that now shows automation.
+ - filter: fingerprint.HasAutomationSignal()
+ apply:
+ - 'DropRequest("automation signal on cookie-bearing client")'
+```
+
+`fingerprint` also offers `IsBot()`, `HasBotSignal()`, `BotSignalCount()`, `BotSignals()`,
+`HasHeadlessSignal()`, `HasMismatchSignal()`, `Platform()`, `Timezone()`, `Language()`,
+`IsMobile()`, `CPUCount()`, `Memory()` —
+
+
+## Custom scenarios on challenge events
+
+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
+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 # what turns the alert into a decision, via 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
new file mode 100644
index 0000000..58b64e6
--- /dev/null
+++ b/skills/crowdsec/references/appsec/bot-detection/deploy.md
@@ -0,0 +1,168 @@
+---
+verified:
+ - date: 2026-08-31
+ version: "1.8.0"
+ env: systemd
+ 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
+ notes: "install + acquisition + engine-level smoke test; no bouncer wired"
+---
+
+# Deploy bot detection
+
+Canonical: ·
+[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 | 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 |
+
+Separately installable building blocks: `…-scoring` (scoring engine, never rejects),
+`…-good-bots` (verified-crawler exclusions), `…-exclude-paths`.
+
+> **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
+```
+
+Prefix: `sudo cscli …` / `docker exec cscli …` / `kubectl exec -n -- cscli …`.
+Docker: prefer `-e COLLECTIONS="crowdsecurity/appsec-bot-challenge"` so it survives a container
+replace.
+
+## 2 — Acquisition
+
+```yaml
+# /etc/crowdsec/acquis.d/appsec.yaml
+listen_addr: 127.0.0.1:7422
+appsec_configs:
+ - crowdsecurity/appsec-default # existing WAF config, if any
+ - crowdsecurity/appsec-bot-*
+labels:
+ type: appsec
+source: appsec
+```
+
+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-*"
+```
+
+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
+sudo cscli appsec-configs list
+sudo grep "WAF challenge runtime initialized" /var/log/crowdsec.log
+```
+
+## 3 — Wire the bouncer
+
+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 —
+.
+
+## 4 — Smoke test
+
+### 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"; }
+
+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 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
+
+```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 few hundred KB containing `CrowdSec Challenge`.
+
+### c. Prove a bot gets caught
+
+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 … 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 -d # bot_detected, challenge_event=rejected, fail_reason,
+ # request_score, score_reasons=cdp=100
+```
+
+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 |
+|---|---|
+| `…-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
+
+```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 | - |
+```
+
+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
new file mode 100644
index 0000000..1da53f5
--- /dev/null
+++ b/skills/crowdsec/references/appsec/bot-detection/overview.md
@@ -0,0 +1,103 @@
+---
+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)
+
+Canonical: ·
+[how it works](https://docs.crowdsec.net/docs/next/appsec/bot_detection/how_it_works) ·
+[protocol](https://docs.crowdsec.net/docs/next/appsec/bot_detection/challenge_protocol)
+
+Version and prerequisites: SKILL.md § Step 1.6. **Alpha upstream** — config keys, expr helpers
+and shipped hub rules may change between releases.
+
+## Mechanism
+
+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.
+
+`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
+
+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:"…", 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
+
+Extends [../overview.md](../overview.md) § Terminology.
+
+| Term | Meaning |
+|---|---|
+| Fingerprint / FSID | Device fingerprint and stable id; in alerts as `fingerprint_id` |
+| Difficulty | PoW cost: `disabled` / `low` / `medium` (default) / `high` / `impossible` |
+| 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 use a separate source from WAF rule matches, so scenarios never collide:
+
+| | 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` |
+
+`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
+
+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:
+
+```
+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
+
+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
new file mode 100644
index 0000000..4d78c75
--- /dev/null
+++ b/skills/crowdsec/references/appsec/bot-detection/troubleshoot.md
@@ -0,0 +1,178 @@
+---
+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. Non-challenge AppSec problems:
+[../troubleshoot.md](../troubleshoot.md).
+
+Locate the break with `sudo cscli metrics show bot-detection`:
+
+| Stuck at zero | See |
+|---|---|
+| `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 — Won't start after enabling
+
+`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 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
+```
+
+Other startup fatals:
+
+| Error | Fix |
+|---|---|
+| `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 cookie is not surviving the round trip. `Cookies Invalid` counts clients presenting a
+cookie the runtime will not open.
+
+| Cause | Check |
+|---|---|
+| 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
+```
+
+## 5 — Legitimate clients challenged or blocked
+
+The exempt table (`Bot Detection — Exempted`, one row per reason) shows which exclusions fire.
+
+**Real crawler challenged.** `MatchKnownBot` needs an identity proof, not a user-agent:
+`dig -x +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).
+
+**Everything blocked, not challenged.** The bouncer does not support bot detection and falls
+back to `ban` — [./overview.md](./overview.md) § Bouncer support.
+
+**A human challenged repeatedly** is § 4.
+
+## 6 — Too many / too few rejections
+
+```bash
+sudo cscli appsec-configs list | grep scoring
+```
+
+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 `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).
+
+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 `appsec-challenge` and `appsec-challenge-infra`.
+
+| Column | Meaning |
+|---|---|
+| `Requested` / `Submitted` | Pages served / submissions received |
+| `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 process-global housekeeping; a regeneration rate far above `key_rotation_interval`
+means something is restarting the runtime.
+
+`cscli metrics show appsec-engine` gains `Ch. Requested` / `Ch. Accepted` / `Ch. Rejected`.
+
+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`).
+
+## 8 — Verbose logs
+
+`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 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.
+
+## 9 — Capture a fingerprint
+
+```yaml
+ on_challenge_submit:
+ - filter: "fingerprint.IsBot()"
+ apply:
+ - 'DumpFingerprint("suspected-automation")'
+```
+
+Writes JSONL to `/fingerprint_dumps/crowdsec_fp_dump_suspected-automation.jsonl`
+(fingerprint plus client IP, UA, host, URI, timestamp).
+
+> 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.
+
+## 10 — Console shows the alert without detail
+
+Context (`score_reasons`, `fingerprint_id`, `request_score`) is hidden by default — enable the
+context column or Comfort view. Locally: `sudo cscli alerts inspect -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`