diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index db5e65b..7c514db 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -30,3 +30,9 @@ jobs: run: | npm install -g @anthropic-ai/claude-code claude plugin validate . + + # Reports per-doc verification coverage (date / version / env) and flags + # stale or missing records. Gaps and staleness are informational and do + # not fail the build; a malformed `verified:` block does. + - name: Verification coverage report + run: python3 skills/crowdsec/scripts/check-verification.py diff --git a/CLAUDE.md b/CLAUDE.md index ddfacce..5c893b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,9 +10,9 @@ Conventions for authoring this skill. This governs how skill content is **writte - **Write only the final, correct information.** Never record self-corrections, dead ends, or "actually, it turned out…" narration discovered while authoring. The reader gets the conclusion, not the journey. This applies equally to inline expected-output hints: state the correct - outcome, never the wrong-then-fixed version. Do not annotate content as *verified* (no - "(verified)", "Verified on…", "verified gotcha") — verification is guaranteed by this file, - not restated per-doc. + outcome, never the wrong-then-fixed version. Do not annotate verification *inline in prose* (no + "(verified)", "Verified on…", "verified gotcha"). Record verification in the per-file `verified:` + frontmatter block instead — see **Verification tracking** below. - **Keep `SKILL.md` a router.** It is an index and decision layer that points to `references/`. 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, @@ -34,4 +34,35 @@ Conventions for authoring this skill. This governs how skill content is **writte behavior as confirmed. - **Avoid duplicates** by checking in the existing skill documentation if the information is already present. If it is, ensure it's properly linked/routed, but don't duplicate instruction, code blocks or configurations. +## Verification tracking + +Verification is recorded **per reference doc**, in a `verified:` YAML frontmatter block, so freshness +is queryable and drift is visible. A doc with no `verified:` block has never been confirmed against a +real environment. + +```yaml +--- +verified: + - date: 2026-05-22 # ISO 8601 (YYYY-MM-DD); the day the recipes were run + version: "1.6.5" # CrowdSec engine version, from `cscli version` + env: docker # systemd | docker | k8s (free-form allowed, e.g. opnsense) + notes: "deploy + reload path only" # optional, short scope note +--- +``` + +Rules: + +- **One entry per env.** Re-verifying the same env updates that entry in place (bump `date` and + `version`); verifying a new env appends a new entry. This keeps "cover every environment" + auditable. +- **Stamp it when you verify it.** When you run a doc's commands against the real test environment + (see Testing) and observe them work, add or update that doc's `verified:` entry in the same change: + `date` = today, `version` = the output of `cscli version`, `env` = the detected environment. The + record and the verification happen together — never stamp from memory or inference. +- Use ISO 8601 dates so the checker can sort and age them. +- Don't pre-seed unverified docs with empty blocks; absence *is* the "never verified" signal. + +`skills/crowdsec/scripts/check-verification.py` (stdlib-only, safe to run locally) reports coverage, +lists docs with no block, flags entries older than 180 days, and fails on a malformed block. + diff --git a/skills/crowdsec/references/appsec/configure.md b/skills/crowdsec/references/appsec/configure.md index c1f39af..91d92bb 100644 --- a/skills/crowdsec/references/appsec/configure.md +++ b/skills/crowdsec/references/appsec/configure.md @@ -1,3 +1,11 @@ +--- +verified: + - date: 2026-05-22 + version: "1.7.8" + env: systemd + notes: "appsec-configs/rules list+inspect, metrics rules table; fixed eval-time claim" +--- + # AppSec — Configure Canonical docs: · rule management · hooks · alerts & scenarios · API validation @@ -132,7 +140,7 @@ For captcha responses, configuration lives on the **bouncer** (captcha provider ## Performance levers - `request_body_limit` (engine config) caps how much of the request body AppSec processes — defaults are usually fine; raise for APIs with large legitimate payloads, lower for static-only fronts. -- Rule load order is automatic; per-rule eval time appears in `cscli metrics show appsec` once you generate traffic. +- Rule load order is automatic; per-rule **trigger counts** appear in `cscli metrics show appsec` once you generate traffic (the Rules Metrics table) — useful for spotting hot rules, though `cscli` does not report per-rule eval time. - Inband evaluation adds latency on the request path. Out-of-band is asynchronous and does not. - Move expensive rules (large regex, body inspection) to out-of-band if latency matters more than per-request blocking. diff --git a/skills/crowdsec/references/appsec/deploy.md b/skills/crowdsec/references/appsec/deploy.md index c4a0274..33ba944 100644 --- a/skills/crowdsec/references/appsec/deploy.md +++ b/skills/crowdsec/references/appsec/deploy.md @@ -1,3 +1,15 @@ +--- +verified: + - date: 2026-05-21 + version: "1.7.8" + env: systemd + notes: "nginx acquisition + AppSec deploy" + - date: 2026-05-22 + version: "1.7.8" + env: k8s + notes: "k8s with traefik + AppSec" +--- + # AppSec — Deploy Canonical docs: · quickstart · rules deploy · advanced deployments @@ -112,7 +124,7 @@ The smoke test above proves the WAF works. For production you point a real bounc | Bouncer | Where to set the AppSec endpoint | |---|---| | `crowdsec-nginx-bouncer` (lua module) | `APPSEC_URL=http://127.0.0.1:7422` in `/etc/crowdsec/bouncers/crowdsec-nginx-bouncer.conf` (shell-style `KEY=VALUE`, empty by default = WAF off). The self-registered `API_KEY` already serves AppSec — reuse it. | -| Traefik (`maxlerebourg/crowdsec-bouncer-traefik-plugin`) | Flat plugin options: `crowdsecAppsecEnabled: true` (default false), `crowdsecAppsecHost: crowdsec:7422` (host:port, no scheme), and the bouncer key in `crowdsecLapiKey`. Full recipe in [../configure/bouncers/web-servers.md](../configure/bouncers/web-servers.md) § Traefik. | +| Traefik (`maxlerebourg/crowdsec-bouncer-traefik-plugin`) | Flat plugin options: `crowdsecAppsecEnabled: true` (default false), `crowdsecAppsecHost: :` (no scheme — `crowdsec:7422` in Docker Compose where the container is named `crowdsec`; `-appsec-service..svc.cluster.local:7422` in Kubernetes), and the bouncer key in `crowdsecLapiKey`. In Kubernetes the Middleware `spec.plugin.` must match the key registered in Traefik's `experimental.plugins.` — NOT the module name `crowdsec-bouncer-traefik-plugin`. Full recipe in [../configure/bouncers/web-servers.md](../configure/bouncers/web-servers.md) § Traefik. | | `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. | @@ -130,7 +142,7 @@ See also: [../configure/bouncers/web-servers.md](../configure/bouncers/web-serve | **systemd / bare-metal** | The recipe above as-is. | | **OPNsense / FreeBSD** | Config root is `/usr/local/etc/crowdsec/`; drop acquisition in `acquis.d/appsec.yaml`. The `os-crowdsec` plugin manages the engine — reload with `service crowdsec reload`. No Lua module in the OPNsense nginx package: use the Caddy-based bouncer instead (see [../configure/bouncers/web-servers.md](../configure/bouncers/web-servers.md) § Caddy). Note the LAPI port conflict below. | | **Docker / compose** | AppSec runs inside the crowdsec container and must `listen_addr: 0.0.0.0:7422`. Bouncer containers reach it via the service name + internal port (`appsec_url: http://crowdsec:7422`), not the published port. The acquisition file is mounted from the host or baked into a customised image. `docker compose exec crowdsec cscli appsec-*` for management commands. **Containerized lua bouncers need a DNS `resolver` — see [../install/docker.md](../install/docker.md) § Bouncer key bootstrap.** | -| **Kubernetes / Helm** | The official chart has `appsec.enabled: true` plus values for `appsec.listen_addr`, `appsec.config`, and a separate `appsec` Service. Bouncers target the AppSec Service DNS name. Required collections/rules can be listed in the chart's hub config. | +| **Kubernetes / Helm** | Set `appsec.enabled: true`, `appsec.acquisitions` (list, not scalar — use `appsec_configs:` plural), and `appsec.env` with `COLLECTIONS`. **Also required:** either `agent.enabled: false` or a populated `agent.acquisition` — the chart template rejects values with no agent acquisition at all. Use `helm upgrade --install` (plain `helm upgrade` fails on a fresh cluster with no prior release). The chart creates a service named `-appsec-service`; bouncers reach AppSec at `-appsec-service..svc.cluster.local:7422`. For the Traefik plugin: Traefik needs a writable `/plugins-storage` volume (add an `emptyDir` via `deployment.additionalVolumes` + `additionalVolumeMounts`) and the plugin must be registered under `experimental.plugins.` in Traefik Helm values — the `spec.plugin.` in the Middleware must match that same key name. Create the bouncer key with `kubectl exec -n crowdsec deploy/crowdsec-lapi -- cscli bouncers add `. Cross-namespace Middleware references are disabled by default — either put the Middleware in the same namespace as the IngressRoute, or enable `providers.kubernetesCRD.allowCrossNamespace: true` in Traefik. | ### OPNsense / FreeBSD: LAPI port conflict diff --git a/skills/crowdsec/references/appsec/overview.md b/skills/crowdsec/references/appsec/overview.md index f8c1217..223927d 100644 --- a/skills/crowdsec/references/appsec/overview.md +++ b/skills/crowdsec/references/appsec/overview.md @@ -1,3 +1,11 @@ +--- +verified: + - date: 2026-05-22 + version: "1.7.8" + env: systemd + notes: "listener 7422, configs/rules paths, out-of-band scenario→ban path; conceptual doc" +--- + # AppSec — Overview Canonical docs: · request lifecycle · protocol · FAQ diff --git a/skills/crowdsec/references/appsec/troubleshoot.md b/skills/crowdsec/references/appsec/troubleshoot.md index 4fa7e4e..b8da0a1 100644 --- a/skills/crowdsec/references/appsec/troubleshoot.md +++ b/skills/crowdsec/references/appsec/troubleshoot.md @@ -1,3 +1,11 @@ +--- +verified: + - date: 2026-05-22 + version: "1.7.8" + env: systemd + notes: "metrics show appsec, listener on 7422, appsec-rules/configs inspect; fixed eval-time claim" +--- + # AppSec — Troubleshoot Canonical docs: · FAQ · benchmark @@ -159,7 +167,7 @@ Inspect the bouncer's own log for `captcha` lines; it should announce when it's sudo cscli metrics show appsec ``` -Per-rule eval time shows up in the rules table. Suspects: +The Rules Metrics table shows a per-rule **Triggered** count (how often each rule matched), not timing — use it to spot a hot rule worth moving out-of-band. `cscli` does not expose per-rule eval time; for actual latency numbers use the benchmark methodology below. Suspects: - **Expensive regex** in a rule body — move to out-of-band, or replace with a cheaper match. - **Large request bodies** — raise / lower `request_body_limit` in the engine config; the default is conservative. diff --git a/skills/crowdsec/references/configure/acquisition.md b/skills/crowdsec/references/configure/acquisition.md index 499580b..668303f 100644 --- a/skills/crowdsec/references/configure/acquisition.md +++ b/skills/crowdsec/references/configure/acquisition.md @@ -1,3 +1,11 @@ +--- +verified: + - date: 2026-05-22 + version: "1.7.8" + env: systemd + notes: "config keys, crowdsec -t (clean + FATAL bad-source), appsec listener" +--- + # Configure — Acquisition (log sources) Canonical docs: · datasources index diff --git a/skills/crowdsec/references/configure/allowlists.md b/skills/crowdsec/references/configure/allowlists.md index 69ed5df..abe888e 100644 --- a/skills/crowdsec/references/configure/allowlists.md +++ b/skills/crowdsec/references/configure/allowlists.md @@ -1,3 +1,11 @@ +--- +verified: + - date: 2026-05-20 + version: "1.7.8" + env: systemd + notes: "allowlists add/check/list" +--- + # Configure — Allowlists, whitelist parsers, and postoverflows Canonical docs: diff --git a/skills/crowdsec/references/configure/bouncers/firewall.md b/skills/crowdsec/references/configure/bouncers/firewall.md index bfc7ad1..00f5bef 100644 --- a/skills/crowdsec/references/configure/bouncers/firewall.md +++ b/skills/crowdsec/references/configure/bouncers/firewall.md @@ -1,3 +1,11 @@ +--- +verified: + - date: 2026-05-22 + version: "1.7.8" + env: systemd + notes: "nftables bouncer v0.0.34 install+register, nft sets/chains (priority filter-10), ban→cscli set→drop; added stale-key recovery note" +--- + # Bouncers — Firewall (nftables / iptables / ipset) Canonical docs: @@ -49,6 +57,15 @@ If you *also* run `cscli bouncers add` you create a second, unused key — skip Only register manually when the bouncer runs on a **different host** than LAPI (then set `api_url` to the remote LAPI and paste the manual key into the yaml). +> **Gotcha — reinstall over a stale key.** If a previous +> `/etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml` survives a purge/reinstall, +> the postinst may not rewrite the `api_key`, and the service then fails to start with +> `API error: access forbidden` / `bouncer stream halted` in +> `/var/log/crowdsec-firewall-bouncer.log` (and the dpkg `--configure` step errors). +> Re-register: `cscli bouncers delete `, `KEY=$(cscli bouncers add fw-local -o raw)`, +> write it into the yaml's `api_key:`, `systemctl restart crowdsec-firewall-bouncer`. +> See [../../debug/bouncer-not-blocking.md](../../debug/bouncer-not-blocking.md) § 3. + ## 3 — What it creates in nftables The bouncer builds its **own** tables, isolated from your existing ruleset: diff --git a/skills/crowdsec/references/configure/bouncers/web-servers.md b/skills/crowdsec/references/configure/bouncers/web-servers.md index 8d2c639..a0e578c 100644 --- a/skills/crowdsec/references/configure/bouncers/web-servers.md +++ b/skills/crowdsec/references/configure/bouncers/web-servers.md @@ -1,3 +1,15 @@ +--- +verified: + - date: 2026-05-21 + version: "1.7.8" + env: systemd + notes: "nginx bouncer path only" + - date: 2026-05-22 + version: "1.7.8" + env: k8s + notes: "k8s with traefik + AppSec" +--- + # Bouncers — Web servers (nginx, haproxy, apache, Traefik, Caddy) Canonical docs: (per-bouncer pages: nginx, haproxy, apache, traefik, caddy) @@ -330,6 +342,88 @@ docker exec crowdsec cscli metrics show appsec # Processed/Blocked increment - **`stream` lag:** a fresh ban lands within `updateIntervalSeconds`; immediate ban-then-curl looks like a failure. (See [../../debug/bouncer-not-blocking.md](../../debug/bouncer-not-blocking.md).) +### Kubernetes (Helm) — extra gotchas + +Deploying the plugin on the official Traefik Helm chart requires several steps the upstream docs omit: + +**1. Writable plugin storage** + +The Traefik Helm chart mounts a read-only root filesystem by default. Traefik crashes at startup with `"unable to create directory /plugins-storage/sources: read-only file system"` unless you give it a writable volume: + +```yaml +# traefik values.yaml +experimental: + plugins: + bouncer: + moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" + version: "v1.4.4" + +deployment: + additionalVolumes: + - name: plugins-storage + emptyDir: {} + +additionalVolumeMounts: + - name: plugins-storage + mountPath: /plugins-storage +``` + +Use the `experimental.plugins` section (not `additionalArguments`) — the chart handles the correct flag format. + +**2. Middleware must use the plugin key name, not the module name** + +The Middleware `spec.plugin.` must match the key in `experimental.plugins.` (here: `bouncer`), **not** the module name `crowdsec-bouncer-traefik-plugin`. Error if wrong: `"plugin: unknown plugin type: crowdsec-bouncer-traefik-plugin"`. + +```yaml +# Correct Kubernetes Middleware +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: crowdsec + namespace: traefik +spec: + plugin: + bouncer: # <-- key from experimental.plugins.bouncer + enabled: true + crowdsecMode: stream + crowdsecLapiScheme: http + crowdsecLapiHost: crowdsec-service.crowdsec.svc.cluster.local:8080 + crowdsecLapiKey: + httpTimeoutSeconds: 10 + crowdsecAppsecEnabled: true # WAF off by default — must set true + crowdsecAppsecHost: crowdsec-appsec-service.crowdsec.svc.cluster.local:7422 + crowdsecAppsecFailureBlock: true + crowdsecAppsecUnreachableBlock: true + forwardedHeadersTrustedIPs: + - 10.0.0.0/8 + - 192.168.0.0/16 +``` + +`crowdsecAppsecHost` must be the full Kubernetes service DNS name (`-appsec-service..svc.cluster.local:7422`), not the Docker Compose shortname `crowdsec:7422`. + +**3. Cross-namespace Middleware is blocked by default** + +If your IngressRoute lives in a different namespace than the Middleware, Traefik rejects the reference: `"allowCrossNamespace is disabled, cross-namespace are disallowed"`. Either: +- put the Middleware in the same namespace as the IngressRoute, or +- enable in Traefik values: `providers.kubernetesCRD.allowCrossNamespace: true` (and accept the warning logged on startup). + +**4. Bouncer key for Kubernetes** + +There is no auto-registration in the Helm chart. Mint the key manually: + +```bash +kubectl exec -n crowdsec deploy/crowdsec-lapi -- cscli bouncers add traefik-bouncer +# outputs the key — copy it into the Middleware crowdsecLapiKey +``` + +**5. AppSec Helm values (what actually works)** + +The CrowdSec AppSec hostname in the Traefik Middleware (`crowdsec-appsec-service`) comes from the chart's release name. With `helm install crowdsec crowdsec/crowdsec -n crowdsec ...`, the service is: +- LAPI: `crowdsec-service.crowdsec.svc.cluster.local:8080` +- AppSec: `crowdsec-appsec-service.crowdsec.svc.cluster.local:7422` + +After a `kubectl rollout restart deployment/traefik -n traefik`, confirm the plugin connects: `cscli bouncers list` (via `kubectl exec`) should show `traefik-bouncer` with a recent `Last API pull`. + ## Caddy — `github.com/hslatman/caddy-crowdsec-bouncer` WAF-capable Caddy module ([`hslatman/caddy-crowdsec-bouncer`](https://github.com/hslatman/caddy-crowdsec-bouncer)). diff --git a/skills/crowdsec/references/configure/hub.md b/skills/crowdsec/references/configure/hub.md index facb9a4..374eba4 100644 --- a/skills/crowdsec/references/configure/hub.md +++ b/skills/crowdsec/references/configure/hub.md @@ -1,3 +1,11 @@ +--- +verified: + - date: 2026-05-22 + version: "1.7.8" + env: systemd + notes: "update/upgrade, list -o raw, collections install+remove, inspect tainted fields" +--- + # Configure — Hub management Canonical docs: · `cscli hub` reference @@ -13,9 +21,11 @@ Install a **collection** and it pulls every item it depends on. Installing ```bash sudo cscli collections install crowdsecurity/wordpress -# scenarios: crowdsecurity/http-bf-wordpress_bf, crowdsecurity/http-wordpress_user-enum, -# crowdsecurity/http-wordpress_wpconfig -# collections: crowdsecurity/wordpress +# enabling scenarios:crowdsecurity/http-bf-wordpress_bf +# enabling scenarios:crowdsecurity/http-wordpress_wpconfig +# enabling scenarios:crowdsecurity/http-wordpress_user-enum +# enabling collections:crowdsecurity/wordpress +# # Run 'sudo systemctl reload crowdsec' for the new configuration to be effective. ``` diff --git a/skills/crowdsec/references/configure/profiles.md b/skills/crowdsec/references/configure/profiles.md index 5827e0e..fa21f82 100644 --- a/skills/crowdsec/references/configure/profiles.md +++ b/skills/crowdsec/references/configure/profiles.md @@ -1,3 +1,11 @@ +--- +verified: + - date: 2026-05-22 + version: "1.7.8" + env: systemd + notes: "profiles.yaml structure, no cscli profiles cmd, simulation status, decision add/list/delete" +--- + # Configure — Profiles (decisions, durations, simulation) Canonical docs: · post-install profiles diff --git a/skills/crowdsec/references/debug/bouncer-not-blocking.md b/skills/crowdsec/references/debug/bouncer-not-blocking.md index d8b2bb5..a8c4e93 100644 --- a/skills/crowdsec/references/debug/bouncer-not-blocking.md +++ b/skills/crowdsec/references/debug/bouncer-not-blocking.md @@ -1,3 +1,11 @@ +--- +verified: + - date: 2026-05-22 + version: "1.7.8" + env: systemd + notes: "allowlists/decisions/bouncers ladder, LAPI curl 200, firewall nft sets/chains/counter" +--- + # Debug — Decisions exist but bouncer not blocking Canonical docs: · bouncers index diff --git a/skills/crowdsec/references/debug/common-errors.md b/skills/crowdsec/references/debug/common-errors.md index 0e8d00f..fc590b6 100644 --- a/skills/crowdsec/references/debug/common-errors.md +++ b/skills/crowdsec/references/debug/common-errors.md @@ -1,3 +1,11 @@ +--- +verified: + - date: 2026-05-22 + version: "1.7.8" + env: systemd + notes: "diagnostic commands + ExecStartPre/-t, machines, geoip path; error strings are catalog" +--- + # Debug — Common errors (string → cause catalog) Canonical docs: diff --git a/skills/crowdsec/references/debug/no-alerts.md b/skills/crowdsec/references/debug/no-alerts.md index 9bfe81d..8ee0407 100644 --- a/skills/crowdsec/references/debug/no-alerts.md +++ b/skills/crowdsec/references/debug/no-alerts.md @@ -1,3 +1,11 @@ +--- +verified: + - date: 2026-05-22 + version: "1.7.8" + env: systemd + notes: "metrics show scenarios, whitelist RFC1918 ranges, simulation status, explain replay" +--- + # Debug — Scenarios not firing (no alerts) Canonical docs: · `cscli metrics` diff --git a/skills/crowdsec/references/debug/parsing.md b/skills/crowdsec/references/debug/parsing.md index 68bc329..081714d 100644 --- a/skills/crowdsec/references/debug/parsing.md +++ b/skills/crowdsec/references/debug/parsing.md @@ -1,3 +1,11 @@ +--- +verified: + - date: 2026-05-22 + version: "1.7.8" + env: systemd + notes: "metrics show acquisition + explain --log/--file type matching" +--- + # Debug — Logs not being parsed Canonical docs: · `cscli metrics` diff --git a/skills/crowdsec/references/debug/triage.md b/skills/crowdsec/references/debug/triage.md index 975858e..86c59f5 100644 --- a/skills/crowdsec/references/debug/triage.md +++ b/skills/crowdsec/references/debug/triage.md @@ -1,3 +1,11 @@ +--- +verified: + - date: 2026-05-22 + version: "1.7.8" + env: systemd + notes: "diagnose.sh support dump + triage funnel commands" +--- + # Debug — First-look triage Use this when the user reports a CrowdSec problem and you don't yet know the shape of it. diff --git a/skills/crowdsec/references/install/bare-metal.md b/skills/crowdsec/references/install/bare-metal.md index 8bc8cba..a871584 100644 --- a/skills/crowdsec/references/install/bare-metal.md +++ b/skills/crowdsec/references/install/bare-metal.md @@ -1,3 +1,11 @@ +--- +verified: + - date: 2026-05-21 + version: "1.7.8" + env: systemd + notes: "apt + systemd install path" +--- + # Install — bare metal (apt/dnf + systemd) Canonical docs: · post-install diff --git a/skills/crowdsec/references/install/docker.md b/skills/crowdsec/references/install/docker.md index 08e463c..0fc3705 100644 --- a/skills/crowdsec/references/install/docker.md +++ b/skills/crowdsec/references/install/docker.md @@ -1,3 +1,11 @@ +--- +verified: + - date: 2026-05-22 + version: "1.7.8" + env: docker + notes: "compose up, COLLECTIONS install on boot, /logs/auth.log container-path acquisition, 8081:8080 coexistence, bouncers add; teardown -v" +--- + # Install — Docker / docker-compose Canonical docs: · image reference diff --git a/skills/crowdsec/references/operate/health-check.md b/skills/crowdsec/references/operate/health-check.md index cb193ae..1745096 100644 --- a/skills/crowdsec/references/operate/health-check.md +++ b/skills/crowdsec/references/operate/health-check.md @@ -1,3 +1,11 @@ +--- +verified: + - date: 2026-05-22 + version: "1.7.8" + env: systemd + notes: "all 3 probes fire (whitelist removed), self-block→403, capi status; fixed remediation:false drift" +--- + # Operate — Health-check Canonical docs: @@ -37,7 +45,7 @@ curl -I https:///crowdsec-test-NtktlJHV4TfBSK3wvlhiOBnl sudo cscli alerts list -s crowdsecurity/http-generic-test ``` -Expected: one row with `kind: crowdsec`, scope `Ip:`. Then a decision appears in `cscli decisions list` (the default profile bans on this alert, since `Remediation: true`). +Expected: one row with `kind: crowdsec`, scope `Ip:`. The test scenarios are deliberately `remediation: false` (`type: trigger`), so they produce an **alert but no ban decision** — the alert itself is the proof the detection chain works, and the probe won't lock you out. End-to-end bouncer enforcement is proven separately in § 5. **Common failure paths** (in order to check): 1. *No row, no parser hit* → the web server's logs aren't being read. `cscli metrics show acquisition` — does your access log show non-zero "Lines read"? If not, see [../configure/acquisition.md](../configure/acquisition.md). @@ -57,7 +65,7 @@ ssh crowdsec-test-NtktlJHV4TfBSK3wvlhiOBnl@ sudo cscli alerts list -s crowdsecurity/ssh-generic-test ``` -Expected: one row with `kind: crowdsec`, ban decision shortly after. +Expected: one row with `kind: crowdsec`. Like the HTTP probe, `ssh-generic-test` is `remediation: false` — an alert appears, but no ban (by design). **Common failure paths:** 1. *No row* → check `cscli metrics show acquisition` for `/var/log/auth.log` (or wherever sshd logs land). On systems using journald-only logging, the file source may be empty — switch to a journald acquisition. diff --git a/skills/crowdsec/references/operate/upgrades.md b/skills/crowdsec/references/operate/upgrades.md index fa5993d..00aa6ad 100644 --- a/skills/crowdsec/references/operate/upgrades.md +++ b/skills/crowdsec/references/operate/upgrades.md @@ -1,3 +1,11 @@ +--- +verified: + - date: 2026-05-22 + version: "1.7.8" + env: systemd + notes: "apt-cache policy (no-op at latest, packagecloud repo, rollback table), hub upgrade, backup paths; non-destructive" +--- + # Operate — Upgrades, backup, rollback Canonical docs: · `cscli` reference diff --git a/skills/crowdsec/scripts/check-verification.py b/skills/crowdsec/scripts/check-verification.py new file mode 100644 index 0000000..5703d81 --- /dev/null +++ b/skills/crowdsec/scripts/check-verification.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Report verification coverage across the CrowdSec skill's reference docs. + +A reference doc records when its recipes were last exercised against a real +environment in a YAML `verified:` frontmatter block (see CLAUDE.md § +"Verification tracking"). This static checker — stdlib only, safe to run on the +local working copy — walks the docs and reports: + + * a coverage table (doc · last-verified date · version · env · age in days), + * docs with no `verified:` block (coverage gaps), + * entries older than the staleness threshold (default 180 days). + +It exits non-zero only when a present `verified:` block is malformed, so it can +run as a non-blocking CI report while backfill is still in progress. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import sys +from pathlib import Path + +# skills/crowdsec/scripts/check-verification.py -> skills/crowdsec +SKILL_ROOT = Path(__file__).resolve().parent.parent +REFERENCES = SKILL_ROOT / "references" +SKILL_MD = SKILL_ROOT / "SKILL.md" + +CANONICAL_ENVS = {"systemd", "docker", "k8s"} +REQUIRED_KEYS = ("date", "version", "env") +OPTIONAL_KEYS = ("notes",) +ALLOWED_KEYS = set(REQUIRED_KEYS) | set(OPTIONAL_KEYS) + + +class SchemaError(Exception): + """A present `verified:` block does not match the expected schema.""" + + +def split_frontmatter(text: str) -> str | None: + """Return the YAML frontmatter body, or None if the file has no frontmatter. + + Frontmatter is the block between a leading `---` line and the next `---`. + """ + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return None + for i in range(1, len(lines)): + if lines[i].strip() == "---": + return "\n".join(lines[1:i]) + raise SchemaError("frontmatter opened with '---' but never closed") + + +def parse_verified(frontmatter: str) -> list[dict] | None: + """Parse the `verified:` list out of a frontmatter body. + + Targeted parser for the documented schema (a top-level `verified:` key whose + value is a list of flat mappings) — avoids a PyYAML dependency. Returns None + if there is no `verified:` key, or a list of entry dicts otherwise. + """ + lines = frontmatter.splitlines() + start = None + for idx, line in enumerate(lines): + if line.rstrip() == "verified:" or line.rstrip() == "verified: []": + if line.rstrip().endswith("[]"): + return [] + start = idx + 1 + break + if start is None: + return None + + entries: list[dict] = [] + current: dict | None = None + for raw in lines[start:]: + if raw.strip() == "": + continue + # A new top-level key (no leading space) ends the verified block. + if not raw.startswith(" ") and not raw.startswith("\t"): + break + stripped = raw.strip() + if stripped.startswith("- "): + current = {} + entries.append(current) + stripped = stripped[2:].strip() + if not stripped: + continue + if current is None: + raise SchemaError("verified entry data found before any '-' list item") + if ":" not in stripped: + raise SchemaError(f"malformed line in verified entry: {raw!r}") + key, _, value = stripped.partition(":") + current[key.strip()] = value.strip().strip('"').strip("'") + return entries + + +def validate_entry(entry: dict, doc: Path) -> dt.date: + """Validate one verified entry; return its parsed date.""" + unknown = set(entry) - ALLOWED_KEYS + if unknown: + raise SchemaError(f"unknown key(s) {sorted(unknown)} in a verified entry") + missing = [k for k in REQUIRED_KEYS if not entry.get(k)] + if missing: + raise SchemaError(f"verified entry missing required key(s): {missing}") + try: + return dt.date.fromisoformat(entry["date"]) + except ValueError as exc: + raise SchemaError(f"date {entry['date']!r} is not ISO 8601 (YYYY-MM-DD)") from exc + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--stale-days", + type=int, + default=180, + help="flag verified entries older than this many days (default: 180)", + ) + args = parser.parse_args() + + today = dt.date.today() + docs = sorted(REFERENCES.rglob("*.md")) + if SKILL_MD.exists(): + docs.append(SKILL_MD) + if not docs: + print(f"No reference docs found under {REFERENCES}", file=sys.stderr) + return 1 + + rows: list[tuple[str, str, str, str, str]] = [] + gaps: list[str] = [] + stale: list[str] = [] + errors: list[str] = [] + + for doc in docs: + rel = doc.relative_to(SKILL_ROOT).as_posix() + try: + frontmatter = split_frontmatter(doc.read_text(encoding="utf-8")) + entries = parse_verified(frontmatter) if frontmatter is not None else None + except SchemaError as exc: + errors.append(f"{rel}: {exc}") + continue + + if not entries: + gaps.append(rel) + continue + + for entry in entries: + try: + date = validate_entry(entry, doc) + except SchemaError as exc: + errors.append(f"{rel}: {exc}") + continue + age = (today - date).days + env = entry["env"] + env_note = "" if env in CANONICAL_ENVS else " (non-canonical env)" + rows.append((rel, entry["date"], entry["version"], env + env_note, f"{age}d")) + if age > args.stale_days: + stale.append(f"{rel} [{env}] verified {entry['date']} ({age}d ago)") + + headers = ("doc", "last verified", "version", "env", "age") + widths = [len(h) for h in headers] + for row in rows: + widths = [max(w, len(c)) for w, c in zip(widths, row)] + + def fmt(cols: tuple[str, ...]) -> str: + return " ".join(c.ljust(w) for c, w in zip(cols, widths)) + + print("== Verification coverage ==") + if rows: + print(fmt(headers)) + print(fmt(tuple("-" * w for w in widths))) + for row in sorted(rows): + print(fmt(row)) + else: + print("(no docs carry a verified: block yet)") + + print(f"\nVerified: {len(rows)} record(s) across {len(docs) - len(gaps) - len(errors)} doc(s)") + print(f"Coverage gaps (no verified: block): {len(gaps)}") + for g in gaps: + print(f" - {g}") + + if stale: + print(f"\nStale (> {args.stale_days} days): {len(stale)}") + for s in stale: + print(f" ! {s}") + + if errors: + print(f"\nMalformed verified: block(s): {len(errors)}", file=sys.stderr) + for e in errors: + print(f" x {e}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())