selfhostedops-stack/crowdsec/appsec-rules/crs-route-exclusions-by-rule.yaml
Maximilian Grimm 3edef38ba3 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.
2026-09-19 12:56:39 +02:00

33 lines
2.7 KiB
YAML

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"