diff --git a/VERSION b/VERSION index 7df8db9..d2ce8c1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2026.10.3 +2026.10.4 diff --git a/scripts/test-waf-body-inspection.py b/scripts/test-waf-body-inspection.py index e8b957c..ce8830d 100644 --- a/scripts/test-waf-body-inspection.py +++ b/scripts/test-waf-body-inspection.py @@ -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(): diff --git a/templates/hap_coraza_spoe_engine.tpl b/templates/hap_coraza_spoe_engine.tpl index 745ee17..29a698a 100644 --- a/templates/hap_coraza_spoe_engine.tpl +++ b/templates/hap_coraza_spoe_engine.tpl @@ -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 diff --git a/templates/hap_listener.tpl b/templates/hap_listener.tpl index 9ae4b63..f364b63 100644 --- a/templates/hap_listener.tpl +++ b/templates/hap_listener.tpl @@ -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,