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
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:
1 parent
519f2d8ade
commit
cbbd15b787
4 files changed
+33
-4
No files matched your search
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user