ops-readonly gets two read-only questions, waf_blocked and waf_rule_info. The
rule ids per blocked request are in CrowdSec's alerts, not in its log, and
alerts need machine credentials that can also write, so waf-export.py runs on
the host and the container gets one more read-only mount.
Workflow G lets a local 3B model draft CRS route exclusions as a Forgejo issue.
The model never writes rule syntax, a code node checks every field against the
tool output, the only write permission is an issue in one repo, trusted sources
live in the workflow and the trigger needs a token.
waf-propose.py does the same without a model: 5 of 5 false positives, no
exclusion for an attack, 0.03 s. The model proposed an exclusion for nearly
every attack it saw. Numbers and the traps we hit are in HARDENING.md.
Follow-up measurements showed the first exclusion approach was too broad.
ctl:ruleEngine=Off disables the whole Coraza engine for the route, which also
turns off the native virtual-patching rules: a .env probe on such a route
returned 404 (no WAF) instead of 403.
The exclusion now uses ctl:ruleRemoveById=949110,959100 (the CRS inbound and
outbound anomaly-score blockers) so the engine stays on and virtual patching
keeps firing on the excluded routes. Measured: .env / PHPUnit / .git/config
still 403 on those routes, generic SQLi/XSS pass, non-excluded hosts keep full
CRS (27 of 41). Rule order relative to the CRS rule no longer matters.
Also measured and documented: RemoveInBandRuleByName in a pre_eval hook does
nothing to CRS (SQLi stayed 403 globally and per route); a full authenticated
Vaultwarden flow (register, login, sync, cipher create) passes under full CRS
on the vault host. README traps and numbers updated accordingly.
Measured follow-up to the AppSec section. Virtual patching blocks 7 of 41
probes with near-zero false positives; the OWASP CRS raises that to 27 of 41
but, at default paranoia, blocks six legitimate workflows (git push, Forgejo
issues and file contents, Outline documents, ntfy publish, n8n webhooks).
crowdsec/appsec-rules/crs-route-exclusions.yaml disables the SecLang/Coraza
engine (ctl:ruleEngine=Off) for exactly those application routes, matched by
host + URI, so virtual patching still runs there. crowdsec/appsec-configs/
appsec-tuned.yaml wires it in before the CRS rule. Measured with tuning: zero
false positives, still 27 of 41 blocked on non-excluded routes, about +2 ms
per request, ~15-40 MB more RAM.
Trap: RemoveInBandRuleByName in a pre_eval hook does not disable CRS (it is
SecLang, not a native rule); ctl:ruleEngine in phase 1 is the working lever,
and the exclusion rule must be listed before the CRS rule.
The stack runs vpatch+generic by default (zero false positives, no per-app
tuning). The CRS recipe ships as a documented opt-in in crowdsec/README.md.