Episode 14: an agent reads the WAF blocks, a script beats it, a human merges

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.
This commit is contained in:
Maximilian Grimm 2026-09-19 12:56:39 +02:00
parent 696f3e311d
commit 3edef38ba3
13 changed files with 987 additions and 1 deletions

View file

@ -0,0 +1,33 @@
name: selfhostedops/crs-route-exclusions-by-rule
# Episode 14: per route, remove only the CRS rules that fired on legitimate
# requests there, not the blocking rule 949110 as in episode 13. The engine
# stays on, virtual patching keeps working, and every CRS rule that did NOT
# fire on a route still protects it (measured: SQLi on the git API stays 403).
#
# This file is what a human merged from an advisor issue on the demo box. The
# rule ids are from OUR traffic. Do not copy them, generate your own:
# curl -s -H "x-ops-token: $T" http://ops-readonly:8098/read/waf_blocked \
# | ops-readonly/waf-propose.py <your trusted source IPs>
# Replace ops.example.com with your domain. SecLang needs the literal host.
seclang_rules:
# Forgejo REST API: issue text and file contents that look like attacks
- |
SecRule REQUEST_HEADERS:Host "@streq git.ops.example.com" "id:900201,phase:1,pass,nolog,chain,msg:'route exclusion 1'"
SecRule REQUEST_URI "@beginsWith /api/v1/" "ctl:ruleRemoveById=930120,ctl:ruleRemoveById=932160,ctl:ruleRemoveById=932235,ctl:ruleRemoveById=941100,ctl:ruleRemoveById=941110,ctl:ruleRemoveById=941160,ctl:ruleRemoveById=941390"
# git smart HTTP: 920420 is "content type not allowed", nothing about the payload.
# One entry per repository. Use "@contains .git/git-" to cover all of them.
- |
SecRule REQUEST_HEADERS:Host "@streq git.ops.example.com" "id:900202,phase:1,pass,nolog,chain,msg:'route exclusion 2'"
SecRule REQUEST_URI "@beginsWith /youruser/yourrepo.git/" "ctl:ruleRemoveById=920420"
# Outline: documents with code blocks
- |
SecRule REQUEST_HEADERS:Host "@streq wiki.ops.example.com" "id:900203,phase:1,pass,nolog,chain,msg:'route exclusion 3'"
SecRule REQUEST_URI "@beginsWith /api/documents.create" "ctl:ruleRemoveById=930100,ctl:ruleRemoveById=930110,ctl:ruleRemoveById=930120,ctl:ruleRemoveById=932160,ctl:ruleRemoveById=932230,ctl:ruleRemoveById=932235,ctl:ruleRemoveById=932260,ctl:ruleRemoveById=932370,ctl:ruleRemoveById=941100,ctl:ruleRemoveById=941110,ctl:ruleRemoveById=941160,ctl:ruleRemoveById=941180,ctl:ruleRemoveById=941390,ctl:ruleRemoveById=942100,ctl:ruleRemoveById=942190,ctl:ruleRemoveById=942350"
# ntfy: message text with shell syntax
- |
SecRule REQUEST_HEADERS:Host "@streq ntfy.ops.example.com" "id:900204,phase:1,pass,nolog,chain,msg:'route exclusion 4'"
SecRule REQUEST_URI "@beginsWith /yourtopic" "ctl:ruleRemoveById=932230"
# n8n webhooks: JSON bodies with markup
- |
SecRule REQUEST_HEADERS:Host "@streq n8n.ops.example.com" "id:900205,phase:1,pass,nolog,chain,msg:'route exclusion 5'"
SecRule REQUEST_URI "@beginsWith /webhook/" "ctl:ruleRemoveById=941100,ctl:ruleRemoveById=941110,ctl:ruleRemoveById=941160"