feat(waf): log why Coraza acted (wrule/wrules/wscore); VERSION 2026.10.4
HAProxy Manager Build and Push / Build-and-Push (push) Successful in 3m14s

coraza-spoa v0.7.1 already returns the interrupting rule id
(txn.coraza.ruleid) and, when the request message sends
exportRuleIDs=bool(true), the comma list of every matched CRS attack
rule (txn.coraza.rule_ids) plus the inbound anomaly score. Send the arg
and append all three to the access log so a block's cause (e.g.
920440,949110 score 5) is visible in haproxy.log without opening the
Coraza audit log. Fields are appended last; existing ones unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-16 08:20:46 -07:00
1 parent 519f2d8ade
commit cbbd15b787
4 files changed
+33 -4

No files matched your search

+1 -1
View File
@@ -1 +1 @@
2026.10.3
2026.10.4
+15 -1
View File
@@ -160,7 +160,16 @@ class ListenerWithCoraza(unittest.TestCase):
def test_access_log_carries_coverage_fields(self):
lf = [l for l in self.lines if l.startswith('log-format ')]
self.assertEqual(len(lf), 1)
self.assertTrue(lf[0].endswith('wafb=%[var(txn.waf_body)] wafe=%[var(txn.coraza.error)]"'))
self.assertIn('wafb=%[var(txn.waf_body)] wafe=%[var(txn.coraza.error)]', lf[0])
def test_access_log_carries_waf_verdict_fields(self):
"""wrule/wrules/wscore are appended LAST so any consumer anchored on
the earlier fields keeps working."""
lf = [l for l in self.lines if l.startswith('log-format ')]
self.assertEqual(len(lf), 1)
self.assertTrue(lf[0].endswith(
'wrule=%[var(txn.coraza.ruleid)] wrules=%[var(txn.coraza.rule_ids)] '
'wscore=%[var(txn.coraza.anomaly_score)]"'))
def test_fail_open_preserved(self):
"""Nothing may deny on var(txn.coraza.error): an SPOA outage must not
@@ -247,6 +256,11 @@ class CorazaTransactionIdIsTheRequestReference(unittest.TestCase):
self.assertIn('coraza-req', self.messages)
self.assertEqual(self.args_of('coraza-req').get('id'), 'unique-id')
def test_request_message_exports_rule_ids(self):
"""Without exportRuleIDs coraza-spoa never sets txn.coraza.rule_ids
and the access log's wrules= is always '-'."""
self.assertEqual(self.args_of('coraza-req').get('exportRuleIDs'), 'bool(true)')
def test_app_is_still_the_first_arg(self):
"""agent.go HandleSPOE Panics unless the FIRST kv entry is `app`."""
for name, args in self.messages.items():
+10 -1
View File
@@ -78,6 +78,15 @@ spoe-agent coraza
# fetch ever came back empty the agent falls back to that random ID, so this
# cannot break inspection.
#
# exportRuleIDs=bool(true) makes coraza-spoa (application.go exportWAFMetrics)
# set var(txn.coraza.rule_ids) = comma list of every matched CRS attack rule
# (910000-959999 + 190000-199999; control rules such as our 99xxxxxxx host
# exceptions and 2000xx body rules are NOT listed), alongside rules_hit and
# anomaly_score which it sets regardless. var(txn.coraza.ruleid) is set on
# every interruption (agent.go) -- for anomaly blocks that is 949110, so
# rule_ids is what actually says WHY. All three are logged in the access
# log (hap_listener.tpl log-format: wrule= wrules= wscore=).
#
# Response inspection (a `coraza-res` message) is NOT configured:
# config.yaml has response_check: false. If it is ever enabled, coraza-res
# MUST also send `id=unique-id` -- HandleResponse
@@ -89,7 +98,7 @@ spoe-agent coraza
# when no proxy headers are present. This is what shows up as `client_ip`
# in /var/log/coraza/audit.log and the rule manager's view-matches panel.
spoe-message coraza-req
args app=str(haproxy) id=unique-id src-ip=var(txn.real_ip) src-port=src_port dst-ip=dst dst-port=dst_port method=method path=path query=query version=req.ver headers=req.hdrs body=req.body
args app=str(haproxy) id=unique-id exportRuleIDs=bool(true) src-ip=var(txn.real_ip) src-port=src_port dst-ip=dst dst-port=dst_port method=method path=path query=query version=req.ver headers=req.hdrs body=req.body
# Group binding for send-spoe-group invocation in the frontend. One group,
# one message; could add more in the future (e.g. coraza-res for response
+7 -1
View File
@@ -68,7 +68,13 @@ frontend web
# wafe is var(txn.coraza.error), set when the SPOE call failed or
# timed out and the request went through UNINSPECTED (fail-open).
# Both are "-" when the WAF is off or the request had no body.
log-format "%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %CC %CS %tsc %ac/%fc/%bc/%sc/%rc %sq/%bq %hr %hs %{+Q}r cip=%[var(txn.real_ip)] id=%ID host=%[capture.req.hdr(0)] ua=%[capture.req.hdr(1)] sni=%[ssl_fc_sni] hv=%[fc_http_major] wafb=%[var(txn.waf_body)] wafe=%[var(txn.coraza.error)]"
# wrule=/wrules=/wscore= WHY Coraza acted, without opening audit.log:
# wrule is the interrupting rule (949110 for anomaly blocks),
# wrules every matched CRS attack rule (e.g. 920440,949110),
# wscore the inbound anomaly score. Set by coraza-spoa via the
# exportRuleIDs SPOE arg (hap_coraza_spoe_engine.tpl). All "-"
# when the WAF is off; wrule is "-" unless Coraza interrupted.
log-format "%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %CC %CS %tsc %ac/%fc/%bc/%sc/%rc %sq/%bq %hr %hs %{+Q}r cip=%[var(txn.real_ip)] id=%ID host=%[capture.req.hdr(0)] ua=%[capture.req.hdr(1)] sni=%[ssl_fc_sni] hv=%[fc_http_major] wafb=%[var(txn.waf_body)] wafe=%[var(txn.coraza.error)] wrule=%[var(txn.coraza.ruleid)] wrules=%[var(txn.coraza.rule_ids)] wscore=%[var(txn.coraza.anomaly_score)]"
# --- URI normalisation (MUST be the first path-touching block here) ---
# Every path-based control in this frontend (the ACME health-check bypass,