Repository: corazawaf/coraza
Pull request #697 (2023-03-08 05:07:59)

Small cleanups to parser
I am investigating at-rest memory usage of coraza-proxy-wasm and digging through parsing / setup code as part of it. Nothing in this PR is particularly impactful yet but will send some cleanup PRs / microoptimizations as I find any.

codecov[bot]: # [Codecov](https://codecov.io/gh/corazawaf/coraza/pull/697?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf) Report
Patch coverage: **`100.00`**% and project coverage change: **`-0.05`** :warning:
> Comparison is base [(`504fdde`)](https://codecov.io/gh/corazawaf/coraza/commit/504fddef55e740ee8dad4fd324dd1f56b89878ae?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf) 80.91% compared to head [(`0c3b080`)](https://codecov.io/gh/corazawaf/coraza/pull/697?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf) 80.86%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           v3/dev     #697      +/-   ##
==========================================
- Coverage   80.91%   80.86%   -0.05%     
==========================================
  Files         146      146              
  Lines        8021     8022       +1     
==========================================
- Hits         6490     6487       -3     
- Misses       1305     1309       +4     
  Partials      226      226              
```

| Flag | Coverage Δ | |
|---|---|---|
| default | `76.86% <100.00%> (-0.01%)` | :arrow_down: |
| examples | `27.20% <75.00%> (-0.02%)` | :arrow_down: |
| ftw | `50.53% <75.00%> (-0.13%)` | :arrow_down: |
| ftw-multiphase | `50.62% <75.00%> (-0.13%)` | :arrow_down: |
| tinygo | `75.87% <100.00%> (-0.06%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/corazawaf/coraza/pull/697?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf) | Coverage Δ | |
|---|---|---|
| [internal/corazawaf/rule.go](https://codecov.io/gh/corazawaf/coraza/pull/697?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf#diff-aW50ZXJuYWwvY29yYXphd2FmL3J1bGUuZ28=) | `76.44% <100.00%> (-0.06%)` | :arrow_down: |
| [internal/seclang/directives.go](https://codecov.io/gh/corazawaf/coraza/pull/697?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf#diff-aW50ZXJuYWwvc2VjbGFuZy9kaXJlY3RpdmVzLmdv) | `70.91% <100.00%> (ø)` | |
| [internal/seclang/rule\_parser.go](https://codecov.io/gh/corazawaf/coraza/pull/697?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf#diff-aW50ZXJuYWwvc2VjbGFuZy9ydWxlX3BhcnNlci5nbw==) | `83.11% <100.00%> (+0.08%)` | :arrow_up: |
| [internal/variables/variablesmap.gen.go](https://codecov.io/gh/corazawaf/coraza/pull/697?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf#diff-aW50ZXJuYWwvdmFyaWFibGVzL3ZhcmlhYmxlc21hcC5nZW4uZ28=) | `77.20% <0.00%> (-2.08%)` | :arrow_down: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/corazawaf/coraza/pull/697?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=corazawaf).

jcchavezs: LGTM

Patch:
diff --git a/actions/ctl.go b/actions/ctl.go
index 3bbd8c2..b2d8d8b 100644
--- a/actions/ctl.go
+++ b/actions/ctl.go
@@ -21,25 +21,27 @@ import (
 type ctlFunctionType int
 
 const (
-	ctlUnknown                  ctlFunctionType = iota
-	ctlRuleRemoveTargetByID     ctlFunctionType = iota
-	ctlRuleRemoveTargetByTag    ctlFunctionType = iota
-	ctlRuleRemoveTargetByMsg    ctlFunctionType = iota
-	ctlAuditEngine              ctlFunctionType = iota
-	ctlAuditLogParts            ctlFunctionType = iota
-	ctlForceRequestBodyVariable ctlFunctionType = iota
-	ctlRequestBodyAccess        ctlFunctionType = iota
-	ctlRequestBodyLimit         ctlFunctionType = iota
-	ctlRuleEngine               ctlFunctionType = iota
-	ctlRuleRemoveByID           ctlFunctionType = iota
-	ctlRuleRemoveByMsg          ctlFunctionType = iota
-	ctlRuleRemoveByTag          ctlFunctionType = iota
-	ctlHashEngine               ctlFunctionType = iota
-	ctlHashEnforcement          ctlFunctionType = iota
-	ctlRequestBodyProcessor     ctlFunctionType = iota
-	ctlResponseBodyAccess       ctlFunctionType = iota
-	ctlResponseBodyLimit        ctlFunctionType = iota
-	ctlDebugLogLevel            ctlFunctionType = iota
+	ctlUnknown                   ctlFunctionType = iota
+	ctlRuleRemoveTargetByID      ctlFunctionType = iota
+	ctlRuleRemoveTargetByTag     ctlFunctionType = iota
+	ctlRuleRemoveTargetByMsg     ctlFunctionType = iota
+	ctlAuditEngine               ctlFunctionType = iota
+	ctlAuditLogParts             ctlFunctionType = iota
+	ctlForceRequestBodyVariable  ctlFunctionType = iota
+	ctlRequestBodyAccess         ctlFunctionType = iota
+	ctlRequestBodyLimit          ctlFunctionType = iota
+	ctlRuleEngine                ctlFunctionType = iota
+	ctlRuleRemoveByID            ctlFunctionType = iota
+	ctlRuleRemoveByMsg           ctlFunctionType = iota
+	ctlRuleRemoveByTag           ctlFunctionType = iota
+	ctlHashEngine                ctlFunctionType = iota
+	ctlHashEnforcement           ctlFunctionType = iota
+	ctlRequestBodyProcessor      ctlFunctionType = iota
+	ctlForceResponseBodyVariable ctlFunctionType = iota
+	ctlResponseBodyProcessor     ctlFunctionType = iota
+	ctlResponseBodyAccess        ctlFunctionType = iota
+	ctlResponseBodyLimit         ctlFunctionType = iota
+	ctlDebugLogLevel             ctlFunctionType = iota
 )
 
 type ctlFn struct {
@@ -133,26 +135,49 @@ func (a *ctlFn) Evaluate(_ rules.RuleMetadata, txS rules.TransactionState) {
 			Bool("value", val).
 			Msg("Forcing request body var")
 	case ctlRequestBodyAccess:
-		val, ok := parseOnOff(a.value)
-		if !ok {
-			tx.DebugLogger().Error().
+		if tx.LastPhase() <= types.PhaseRequestHeaders {
+
+			val, ok := parseOnOff(a.value)
+			if !ok {
+				tx.DebugLogger().Error().
+					Str("ctl", "RequestBodyAccess").
+					Str("value", a.value).
+					Msg("Unknown toggle")
+				return
+			}
+			tx.RequestBodyAccess = val
+		} else {
+			tx.DebugLogger().Warn().
 				Str("ctl", "RequestBodyAccess").
-				Str("value", a.value).
-				Msg("Unknown toggle")
+				Msg("Cannot change request body access after request headers phase")
 			return
 		}
-		tx.RequestBodyAccess = val
 	case ctlRequestBodyLimit:
-		limit, err := strconv.ParseInt(a.value, 10, 64)
-		if err != nil {
-			tx.DebugLogger().Error().
+		if tx.LastPhase() <= types.PhaseRequestHeaders {
+			limit, err := strconv.ParseInt(a.value, 10, 64)
+			if err != nil {
+				tx.DebugLogger().Error().
+					Str("ctl", "RequestBodyLimit").
+					Str("value", a.value).
+					Err(err).
+					Msg("Invalid limit")
+				return
+			}
+			tx.RequestBodyLimit = limit
+		} else {
+			tx.DebugLogger().Warn().
 				Str("ctl", "RequestBodyLimit").
-				Str("value", a.value).
-				Err(err).
-				Msg("Invalid limit")
+				Msg("Cannot change request body limit after request headers phase")
 			return
 		}
-		tx.RequestBodyLimit = limit
+	case ctlRequestBodyProcessor:
+		if tx.LastPhase() <= types.PhaseRequestHeaders {
+			tx.Variables().RequestBodyProcessor().Set(strings.ToUpper(a.value))
+		} else {
+			tx.DebugLogger().Warn().
+				Str("ctl", "RequestBodyProcessor").
+				Msg("Cannot change request body processor after request headers phase")
+		}
 	case ctlRuleEngine:
 		re, err := types.ParseRuleEngineStatus(a.value)
 		if err != nil {
@@ -189,11 +214,72 @@ func (a *ctlFn) Evaluate(_ rules.RuleMetadata, txS rules.TransactionState) {
 				tx.RemoveRuleByID(r.ID_)
 			}
 		}
-	case ctlRequestBodyProcessor:
-		// TODO(jcchavezs): Shall we validate such body processor exists or is it
-		// too ambitious as plugins might register their own at some point in the
-		// lifecycle which does not have to happen before this.
-		tx.Variables().RequestBodyProcessor().Set(strings.ToUpper(a.value))
+
+	case ctlResponseBodyAccess:
+		if tx.LastPhase() <= types.PhaseResponseHeaders {
+			val, ok := parseOnOff(a.value)
+			if !ok {
+				tx.DebugLogger().Error().
+					Str("ctl", "ResponseBodyAccess").
+					Str("value", a.value).
+					Msg("Unknown toggle")
+				return
+			}
+			tx.ResponseBodyAccess = val
+		} else {
+			tx.DebugLogger().Warn().
+				Str("ctl", "ResponseBodyAccess").
+				Msg("Cannot change response body access after response headers phase")
+			return
+		}
+
+	case ctlResponseBodyLimit:
+		if tx.LastPhase() <= types.PhaseResponseHeaders {
+			limit, err := strconv.ParseInt(a.value, 10, 64)
+			if err != nil {
+				tx.DebugLogger().Error().
+					Str("ctl", "ResponseBodyLimit").
+					Str("value", a.value).
+					Err(err).
+					Msg("Invalid limit")
+				return
+			}
+			tx.ResponseBodyLimit = limit
+		} else {
+			tx.DebugLogger().Warn().
+				Str("ctl", "ResponseBodyLimit").
+				Msg("Cannot change response body access after response headers phase")
+			return
+		}
+
+	case ctlForceResponseBodyVariable:
+		val, ok := parseOnOff(a.value)
+		if !ok {
+			tx.DebugLogger().Error().
+				Str("ctl", "ForceResponseBodyVariable").
+				Str("value", a.value).
+				Msg("Unknown toggle")
+			return
+		}
+		tx.ForceResponseBodyVariable = val
+		tx.WAF.Logger.Debug().
+			Str("ctl", "ForceResponseBodyVariable").
+			Bool("value", val).
+			Msg("Forcing response body var")
+	case ctlResponseBodyProcessor:
+		if tx.LastPhase() <= types.PhaseResponseHeaders {
+			// We are still in time to set the response body processor
+			// TODO(jcchavezs): Who should hold this knowledge?
+			// TODO(jcchavezs): Shall we validate such body processor exists or is it
+			// too ambitious as plugins might register their own at some point in the
+			// lifecycle which does not have to happen before this.
+			tx.Variables().ResponseBodyProcessor().Set(strings.ToUpper(a.value))
+		} else {
+			tx.DebugLogger().Warn().
+				Str("ctl", "ResponseBodyLimit").
+				Msg("Cannot change response body access after response headers phase")
+			return
+		}
 	case ctlHashEngine:
 		// Not supported yet
 	case ctlHashEnforcement:
@@ -235,18 +321,22 @@ func parseCtl(data string) (ctlFunctionType, string, variables.RuleVariable, str
 		act = ctlAuditEngine
 	case "auditLogParts":
 		act = ctlAuditLogParts
-	case "forceRequestBodyVariable":
-		act = ctlForceRequestBodyVariable
 	case "requestBodyAccess":
 		act = ctlRequestBodyAccess
 	case "requestBodyLimit":
 		act = ctlRequestBodyLimit
 	case "requestBodyProcessor":
 		act = ctlRequestBodyProcessor
+	case "forceRequestBodyVariable":
+		act = ctlForceRequestBodyVariable
+	case "responseBodyProcessor":
+		act = ctlResponseBodyProcessor
 	case "responseBodyAccess":
 		act = ctlResponseBodyAccess
 	case "responseBodyLimit":
 		act = ctlResponseBodyLimit
+	case "forceResponseBodyVariable":
+		act = ctlForceResponseBodyVariable
 	case "ruleEngine":
 		act = ctlRuleEngine
 	case "ruleRemoveById":
diff --git a/bodyprocessors/json.go b/bodyprocessors/json.go
index d8cce85..b084e4c 100644
--- a/bodyprocessors/json.go
+++ b/bodyprocessors/json.go
@@ -13,8 +13,9 @@ import (
 	"github.com/corazawaf/coraza/v3/rules"
 )
 
-type jsonBodyProcessor struct {
-}
+type jsonBodyProcessor struct{}
+
+var _ BodyProcessor = &jsonBodyProcessor{}
 
 func (js *jsonBodyProcessor) ProcessRequest(reader io.Reader, v rules.TransactionVariables, _ Options) error {
 	col := v.ArgsPost()
@@ -32,6 +33,14 @@ func (js *jsonBodyProcessor) ProcessRequest(reader io.Reader, v rules.Transactio
 }
 
 func (js *jsonBodyProcessor) ProcessResponse(reader io.Reader, v rules.TransactionVariables, _ Options) error {
+	col := v.ResponseArgs()
+	data, err := readJSON(reader)
+	if err != nil {
+		return err
+	}
+	for key, value := range data {
+		col.SetIndex(key, 0, value)
+	}
 	return nil
 }
 
@@ -93,8 +102,6 @@ func readItems(json gjson.Result, objKey []byte, res map[string]string) {
 	}
 }
 
-var _ BodyProcessor = &jsonBodyProcessor{}
-
 func init() {
 	Register("json", func() BodyProcessor {
 		return &jsonBodyProcessor{}
diff --git a/internal/corazarules/rule.go b/internal/corazarules/rule.go
index ce2f65e..bcf49c8 100644
--- a/internal/corazarules/rule.go
+++ b/internal/corazarules/rule.go
@@ -3,7 +3,9 @@
 
 package corazarules
 
-import "github.com/corazawaf/coraza/v3/types"
+import (
+	"github.com/corazawaf/coraza/v3/types"
+)
 
 // RuleMetadata is used to store rule metadata
 // that can be used across packages
diff --git a/internal/corazawaf/rule.go b/internal/corazawaf/rule.go
index ffff362..27f898a 100644
--- a/internal/corazawaf/rule.go
+++ b/internal/corazawaf/rule.go
@@ -25,9 +25,6 @@ type ruleActionParams struct {
 	// The name of the action, used for logging
 	Name string
 
-	// Parameters used by the action
-	Param string
-
 	// The action to be executed
 	Function rules.Action
 }
@@ -59,9 +56,6 @@ type ruleVariableException struct {
 // to get values from the transaction's variables
 // It supports xml, regex, exceptions and many more features
 type ruleVariableParams struct {
-	// We store the name for performance
-	Name string
-
 	// If true, the count of results will be returned
 	Count bool
 
@@ -81,9 +75,6 @@ type ruleVariableParams struct {
 }
 
 type ruleTransformationParams struct {
-	// The transformation to be used, used for logging
-	Name string
-
 	// The transformation function to be used
 	Function rules.Transformation
 }
@@ -222,7 +213,10 @@ func (r *Rule) doEvaluate(phase types.RulePhase, tx *Transaction, cache map[tran
 			}
 
 			values = tx.GetField(v)
-			tx.DebugLogger().Debug().Int("rule_id", rid).Msg("Expanding arguments for rule")
+			tx.DebugLogger().Debug().
+				Int("rule_id", rid).
+				Str("variable", v.Variable.Name()).
+				Msg("Expanding arguments for rule")
 			for i, arg := range values {
 				tx.DebugLogger().Debug().Int("rule_id", rid).Msg("Transforming argument for rule")
 				args, errs := r.transformArg(arg, i, cache)
@@ -396,7 +390,6 @@ func (r *Rule) AddVariable(v variables.RuleVariable, key string, iscount bool) e
 	}
 
 	r.variables = append(r.variables, ruleVariableParams{
-		Name:       v.Name(),
 		Count:      iscount,
 		Variable:   v,
 		KeyStr:     strings.ToLower(key),
@@ -459,7 +452,7 @@ func (r *Rule) AddTransformation(name string, t rules.Transformation) error {
 	if t == nil || name == "" {
 		return fmt.Errorf("invalid transformation %q not found", name)
 	}
-	r.transformations = append(r.transformations, ruleTransformationParams{name, t})
+	r.transformations = append(r.transformations, ruleTransformationParams{Function: t})
 	r.transformationsID = transformationID(r.transformationsID, name)
 	return nil
 }
diff --git a/internal/corazawaf/rulegroup.go b/internal/corazawaf/rulegroup.go
index 09a8e64..fe6c183 100644
--- a/internal/corazawaf/rulegroup.go
+++ b/internal/corazawaf/rulegroup.go
@@ -116,7 +116,7 @@ func (rg *RuleGroup) Eval(phase types.RulePhase, tx *Transaction) bool {
 		Int("phase", int(phase)).
 		Msg("Evaluating phase")
 
-	tx.LastPhase = phase
+	tx.lastPhase = phase
 	usedRules := 0
 	ts := time.Now().UnixNano()
 	transformationCache := tx.transformationCache
diff --git a/internal/corazawaf/transaction.go b/internal/corazawaf/transaction.go
index 3037cfe..a88f32e 100644
--- a/internal/corazawaf/transaction.go
+++ b/internal/corazawaf/transaction.go
@@ -57,20 +57,21 @@ type Transaction struct {
 	AllowType corazatypes.AllowType
 
 	// Copies from the WAF instance that may be overwritten by the ctl action
-	AuditEngine              types.AuditEngineStatus
-	AuditLogParts            types.AuditLogParts
-	ForceRequestBodyVariable bool
-	RequestBodyAccess        bool
-	RequestBodyLimit         int64
-	ResponseBodyAccess       bool
-	ResponseBodyLimit        int64
-	RuleEngine               types.RuleEngineStatus
-	HashEngine               bool
-	HashEnforcement          bool
+	AuditEngine               types.AuditEngineStatus
+	AuditLogParts             types.AuditLogParts
+	ForceRequestBodyVariable  bool
+	RequestBodyAccess         bool
+	RequestBodyLimit          int64
+	ForceResponseBodyVariable bool
+	ResponseBodyAccess        bool
+	ResponseBodyLimit         int64
+	RuleEngine                types.RuleEngineStatus
+	HashEngine                bool
+	HashEnforcement           bool
 
 	// Stores the last phase that was evaluated
 	// Used by allow to skip phases
-	LastPhase types.RulePhase
+	lastPhase types.RulePhase
 
 	// Handles request body buffers
 	requestBodyBuffer *BodyBuffer
@@ -78,9 +79,6 @@ type Transaction struct {
 	// Handles response body buffers
 	responseBodyBuffer *BodyBuffer
 
-	// Body processor used to parse JSON, XML, etc
-	bodyProcessor bodyprocessors.BodyProcessor
-
 	// Rules with this id are going to be skipped while processing a phase
 	ruleRemoveByID []int
 
@@ -252,6 +250,8 @@ func (tx *Transaction) Collection(idx variables.RuleVariable) collection.Collect
 		return tx.variables.argsGetNames
 	case variables.ArgsPostNames:
 		return tx.variables.argsPostNames
+	case variables.ResBodyProcessor:
+		return tx.variables.resBodyProcessor
 	case variables.TX:
 		return tx.variables.tx
 	case variables.Rule:
@@ -264,8 +264,7 @@ func (tx *Transaction) Collection(idx variables.RuleVariable) collection.Collect
 	case variables.UrlencodedError:
 		return tx.variables.urlencodedError
 	case variables.ResponseArgs:
-		// TODO(anuraaga): This collection seems to be missing.
-		return nil
+		return tx.variables.responseArgs
 	case variables.ResponseXML:
 		return tx.variables.responseXML
 	case variables.RequestXML:
@@ -718,7 +717,7 @@ func (tx *Transaction) ProcessURI(uri string, method string, httpVersion string)
 // The API consumer is in charge of retrieving the value (e.g. from the host header).
 // It is expected to be executed before calling ProcessRequestHeaders.
 func (tx *Transaction) SetServerName(serverName string) {
-	if tx.LastPhase >= types.PhaseRequestHeaders {
+	if tx.lastPhase >= types.PhaseRequestHeaders {
 		tx.debugLogger.Warn().Msg("SetServerName has been called after ProcessRequestHeaders")
 	}
 	tx.variables.serverName.Set(serverName)
@@ -735,7 +734,7 @@ func (tx *Transaction) ProcessRequestHeaders() *types.Interruption {
 		// Rule engine is disabled
 		return nil
 	}
-	if tx.LastPhase >= types.PhaseRequestHeaders {
+	if tx.lastPhase >= types.PhaseRequestHeaders {
 		// Phase already evaluated
 		tx.debugLogger.Error().Msg("ProcessRequestHeaders has already been called")
 		return tx.interruption
@@ -915,8 +914,8 @@ func (tx *Transaction) ProcessRequestBody() (*types.Interruption, error) {
 		return tx.interruption, nil
 	}
 
-	if tx.LastPhase != types.PhaseRequestHeaders {
-		if tx.LastPhase >= types.PhaseRequestBody {
+	if tx.lastPhase != types.PhaseRequestHeaders {
+		if tx.lastPhase >= types.PhaseRequestBody {
 			// Phase already evaluated or skipped
 			tx.debugLogger.Warn().Msg("ProcessRequestBody should have already been called")
 		} else {
@@ -949,9 +948,6 @@ func (tx *Transaction) ProcessRequestBody() (*types.Interruption, error) {
 		rbp = "URLENCODED"
 		tx.variables.reqbodyProcessor.Set(rbp)
 	}
-	tx.debugLogger.Debug().
-		Str("body_processor", rbp).
-		Msg("Attempting to process request body using")
 	rbp = strings.ToLower(rbp)
 	if rbp == "" {
 		// so there is no bodyprocessor, we don't want to generate an error
@@ -960,15 +956,21 @@ func (tx *Transaction) ProcessRequestBody() (*types.Interruption, error) {
 	}
 	bodyprocessor, err := bodyprocessors.Get(rbp)
 	if err != nil {
-		tx.generateReqbodyError(errors.New("invalid body processor"))
+		tx.generateRequestBodyError(errors.New("invalid body processor"))
 		tx.WAF.Rules.Eval(types.PhaseRequestBody, tx)
 		return tx.interruption, nil
 	}
+
+	tx.debugLogger.Debug().
+		Str("body_processor", rbp).
+		Msg("Attempting to process request body")
+
 	if err := bodyprocessor.ProcessRequest(reader, tx.Variables(), bodyprocessors.Options{
 		Mime:        mime,
 		StoragePath: tx.WAF.UploadDir,
 	}); err != nil {
-		tx.generateReqbodyError(err)
+		tx.debugLogger.Error().Err(err).Msg("Failed to process request body")
+		tx.generateRequestBodyError(err)
 		tx.WAF.Rules.Eval(types.PhaseRequestBody, tx)
 		return tx.interruption, nil
 	}
@@ -988,7 +990,7 @@ func (tx *Transaction) ProcessResponseHeaders(code int, proto string) *types.Int
 		return nil
 	}
 
-	if tx.LastPhase >= types.PhaseResponseHeaders {
+	if tx.lastPhase >= types.PhaseResponseHeaders {
 		// Phase already evaluated
 		tx.debugLogger.Error().Msg("ProcessResponseHeaders has already been called")
 		return tx.interruption
@@ -1153,8 +1155,8 @@ func (tx *Transaction) ProcessResponseBody() (*types.Interruption, error) {
 		return tx.interruption, nil
 	}
 
-	if tx.LastPhase != types.PhaseResponseHeaders {
-		if tx.LastPhase >= types.PhaseResponseBody {
+	if tx.lastPhase != types.PhaseResponseHeaders {
+		if tx.lastPhase >= types.PhaseResponseBody {
 			// Phase already evaluated or skipped
 			tx.debugLogger.Warn().Msg("ProcessResponseBody should have already been called")
 		} else {
@@ -1173,21 +1175,35 @@ func (tx *Transaction) ProcessResponseBody() (*types.Interruption, error) {
 		tx.WAF.Rules.Eval(types.PhaseResponseBody, tx)
 		return tx.interruption, nil
 	}
-	tx.debugLogger.Debug().
-		Msg("Attempting to process response body")
+
 	reader, err := tx.responseBodyBuffer.Reader()
 	if err != nil {
 		return tx.interruption, err
 	}
 
-	buf := new(strings.Builder)
-	length, err := io.Copy(buf, reader)
-	if err != nil {
-		return tx.interruption, err
-	}
+	if bp := tx.variables.resBodyProcessor.Get(); bp != "" {
+		b, err := bodyprocessors.Get(bp)
+		if err != nil {
+			tx.generateResponseBodyError(errors.New("invalid body processor"))
+			tx.WAF.Rules.Eval(types.PhaseResponseBody, tx)
+			return tx.interruption, err
+		}
 
-	tx.variables.responseContentLength.Set(strconv.FormatInt(length, 10))
-	tx.variables.responseBody.Set(buf.String())
+		tx.debugLogger.Debug().Str("body_processor", bp).Msg("Attempting to process response body")
+
+		if err := b.ProcessResponse(reader, tx.Variables(), bodyprocessors.Options{}); err != nil {
+			tx.debugLogger.Error().Err(err).Msg("Failed to process response body")
+			tx.generateResponseBodyError(err)
+		}
+	} else {
+		buf := new(strings.Builder)
+		length, err := io.Copy(buf, reader)
+		if err != nil {
+			return tx.interruption, err
+		}
+		tx.variables.responseContentLength.Set(strconv.FormatInt(length, 10))
+		tx.variables.responseBody.Set(buf.String())
+	}
 	tx.WAF.Rules.Eval(types.PhaseResponseBody, tx)
 	return tx.interruption, nil
 }
@@ -1270,6 +1286,10 @@ func (tx *Transaction) MatchedRules() []types.MatchedRule {
 	return tx.matchedRules
 }
 
+func (tx *Transaction) LastPhase() types.RulePhase {
+	return tx.lastPhase
+}
+
 // AuditLog returns an AuditLog struct, used to write audit logs
 func (tx *Transaction) AuditLog() *auditlog.Log {
 	al := &auditlog.Log{}
@@ -1418,12 +1438,20 @@ func (tx *Transaction) String() string {
 	return res.String()
 }
 
-// generateReqbodyError generates all the error variables for the request body parser
-func (tx *Transaction) generateReqbodyError(err error) {
+// generateRequestBodyError generates all the error variables for the request body parser
+func (tx *Transaction) generateRequestBodyError(err error) {
 	tx.variables.reqbodyError.Set("1")
 	tx.variables.reqbodyErrorMsg.Set(fmt.Sprintf("%s: %s", tx.variables.reqbodyProcessor.Get(), err.Error()))
 	tx.variables.reqbodyProcessorError.Set("1")
-	tx.variables.reqbodyProcessorErrorMsg.Set(string(err.Error()))
+	tx.variables.reqbodyProcessorErrorMsg.Set(err.Error())
+}
+
+// generateResponseBodyError generates all the error variables for the response body parser
+func (tx *Transaction) generateResponseBodyError(err error) {
+	tx.variables.resBodyError.Set("1")
+	tx.variables.resBodyErrorMsg.Set(fmt.Sprintf("%s: %s", tx.variables.resBodyProcessor.Get(), err.Error()))
+	tx.variables.resBodyProcessorError.Set("1")
+	tx.variables.resBodyProcessorErrorMsg.Set(err.Error())
 }
 
 // TransactionVariables has pointers to all the variables of the transaction
@@ -1489,6 +1517,8 @@ type TransactionVariables struct {
 	responseProtocol         *collections.Single
 	responseStatus           *collections.Single
 	responseXML              *collections.Map
+	responseArgs             *collections.Map
+	resBodyProcessor         *collections.Single
 	rule                     *collections.Map
 	serverAddr               *collections.Single
 	serverName               *collections.Single
@@ -1498,6 +1528,10 @@ type TransactionVariables struct {
 	uniqueID                 *collections.Single
 	urlencodedError          *collections.Single
 	xml                      *collections.Map
+	resBodyError             *collections.Single
+	resBodyErrorMsg          *collections.Single
+	resBodyProcessorError    *collections.Single
+	resBodyProcessorErrorMsg *collections.Single
 }
 
 func NewTransactionVariables() *TransactionVariables {
@@ -1534,6 +1568,7 @@ func NewTransactionVariables() *TransactionVariables {
 	v.responseContentLength = collections.NewSingle(variables.ResponseContentLength)
 	v.responseProtocol = collections.NewSingle(variables.ResponseProtocol)
 	v.responseStatus = collections.NewSingle(variables.ResponseStatus)
+	v.responseArgs = collections.NewMap(variables.ResponseArgs)
 	v.serverAddr = collections.NewSingle(variables.ServerAddr)
 	v.serverName = collections.NewSingle(variables.ServerName)
 	v.serverPort = collections.NewSingle(variables.ServerPort)
@@ -1541,6 +1576,10 @@ func NewTransactionVariables() *TransactionVariables {
 	v.statusLine = collections.NewSingle(variables.StatusLine)
 	v.inboundErrorData = collections.NewSingle(variables.InboundErrorData)
 	v.duration = collections.NewSingle(variables.Duration)
+	v.resBodyError = collections.NewSingle(variables.ResBodyError)
+	v.resBodyErrorMsg = collections.NewSingle(variables.ResBodyErrorMsg)
+	v.resBodyProcessorError = collections.NewSingle(variables.ResBodyProcessorError)
+	v.resBodyProcessorErrorMsg = collections.NewSingle(variables.ResBodyProcessorErrorMsg)
 
 	v.filesSizes = collections.NewMap(variables.FilesSizes)
 	v.filesTmpContent = collections.NewMap(variables.FilesTmpContent)
@@ -1554,6 +1593,7 @@ func NewTransactionVariables() *TransactionVariables {
 	v.requestHeadersNames = v.requestHeaders.Names(variables.RequestHeadersNames)
 	v.responseHeaders = collections.NewNamedCollection(variables.ResponseHeaders)
 	v.responseHeadersNames = v.responseHeaders.Names(variables.ResponseHeadersNames)
+	v.resBodyProcessor = collections.NewSingle(variables.ResBodyProcessor)
 	v.geo = collections.NewMap(variables.Geo)
 	v.tx = collections.NewMap(variables.TX)
 	v.rule = collections.NewMap(variables.Rule)
@@ -1838,6 +1878,10 @@ func (v *TransactionVariables) ResponseHeadersNames() collection.Collection {
 	return v.responseHeadersNames
 }
 
+func (v *TransactionVariables) ResponseArgs() collection.Map {
+	return v.responseArgs
+}
+
 func (v *TransactionVariables) RequestHeadersNames() collection.Collection {
 	return v.requestHeadersNames
 }
@@ -1858,6 +1902,10 @@ func (v *TransactionVariables) ResponseXML() collection.Map {
 	return v.responseXML
 }
 
+func (v *TransactionVariables) ResponseBodyProcessor() collection.Single {
+	return v.resBodyProcessor
+}
+
 func (v *TransactionVariables) ArgsNames() collection.Collection {
 	return v.argsNames
 }
@@ -1870,6 +1918,22 @@ func (v *TransactionVariables) ArgsPostNames() collection.Collection {
 	return v.argsPostNames
 }
 
+func (v *TransactionVariables) ResBodyError() collection.Single {
+	return v.resBodyError
+}
+
+func (v *TransactionVariables) ResBodyErrorMsg() collection.Single {
+	return v.resBodyErrorMsg
+}
+
+func (v *TransactionVariables) ResBodyProcessorError() collection.Single {
+	return v.resBodyProcessorError
+}
+
+func (v *TransactionVariables) ResBodyProcessorErrorMsg() collection.Single {
+	return v.resBodyProcessorErrorMsg
+}
+
 // All iterates over the variables. We return both variable and its collection, i.e. key/value, to follow
 // general range iteration in Go which always has a key and value (key is int index for slices). Notably,
 // this is consistent with discussions for custom iterable types in a future language version
@@ -2058,6 +2122,12 @@ func (v *TransactionVariables) All(f func(v variables.RuleVariable, col collecti
 	if !f(variables.ResponseXML, v.responseXML) {
 		return
 	}
+	if !f(variables.ResponseArgs, v.responseArgs) {
+		return
+	}
+	if !f(variables.ResBodyProcessor, v.resBodyProcessor) {
+		return
+	}
 	if !f(variables.Rule, v.rule) {
 		return
 	}
diff --git a/internal/corazawaf/waf.go b/internal/corazawaf/waf.go
index 0784a56..2753a01 100644
--- a/internal/corazawaf/waf.go
+++ b/internal/corazawaf/waf.go
@@ -156,8 +156,7 @@ func (w *WAF) newTransactionWithID(id string) *Transaction {
 	tx.RuleEngine = w.RuleEngine
 	tx.HashEngine = false
 	tx.HashEnforcement = false
-	tx.LastPhase = 0
-	tx.bodyProcessor = nil
+	tx.lastPhase = 0
 	tx.ruleRemoveByID = nil
 	tx.ruleRemoveTargetByID = map[int][]ruleVariableParams{}
 	tx.Skip = 0
diff --git a/internal/seclang/directives.go b/internal/seclang/directives.go
index fd062df..6b6c9b4 100644
--- a/internal/seclang/directives.go
+++ b/internal/seclang/directives.go
@@ -922,7 +922,7 @@ func directiveSecRuleUpdateTargetByID(options *DirectiveOptions) error {
 		return err
 	}
 	rule := options.WAF.Rules.FindByID(id)
-	rp := &RuleParser{
+	rp := RuleParser{
 		rule:           rule,
 		options:        RuleOptions{},
 		defaultActions: map[types.RulePhase][]ruleAction{},
diff --git a/internal/seclang/rule_parser.go b/internal/seclang/rule_parser.go
index ac0e84f..7790cba 100644
--- a/internal/seclang/rule_parser.go
+++ b/internal/seclang/rule_parser.go
@@ -172,9 +172,13 @@ func (p *RuleParser) ParseOperator(operator string) error {
 		operator = "!@rx " + operator[1:]
 	}
 
+	// We clone strings to ensure a slice into larger rule definition isn't kept in
+	// memory just to store operator information.
 	opRaw, opdataRaw, _ := strings.Cut(operator, " ")
+	opRaw = strings.Clone(opRaw)
 	op := strings.TrimSpace(opRaw)
 	opdata := strings.TrimSpace(opdataRaw)
+	opdata = strings.Clone(opdata)
 
 	if op[0] == '@' {
 		// we trim @
@@ -313,7 +317,7 @@ func ParseRule(options RuleOptions) (*corazawaf.Rule, error) {
 	}
 
 	var err error
-	rp := &RuleParser{
+	rp := RuleParser{
 		options:        options,
 		rule:           corazawaf.NewRule(),
 		defaultActions: map[types.RulePhase][]ruleAction{},
@@ -381,7 +385,7 @@ func ParseRule(options RuleOptions) (*corazawaf.Rule, error) {
 		lastChain.Chain = rule
 		return nil, nil
 	}
-	return rp.rule, nil
+	return rule, nil
 }
 
 func getLastRuleExpectingChain(w *corazawaf.WAF) *corazawaf.Rule {
diff --git a/internal/variables/variables.go b/internal/variables/variables.go
index 20cea96..b737944 100644
--- a/internal/variables/variables.go
+++ b/internal/variables/variables.go
@@ -153,6 +153,9 @@ const (
 	// ResponseHeaders can be used as either a collection of all of the response
 	// headers or can be used to inspect selected headers
 	ResponseHeaders
+	// ReseBodyProcessor contains the name of the response body processor used,
+	// no default
+	ResBodyProcessor
 	// Geo contains the location information of the client
 	Geo
 	// RequestCookiesNames contains the names of the request cookies
@@ -227,4 +230,12 @@ const (
 	Userid
 	// IP is kept for compatibility
 	IP
+	// ResBodyError
+	ResBodyError
+	// ResBodyErrorMsg
+	ResBodyErrorMsg
+	// ResBodyProcessorError
+	ResBodyProcessorError
+	// ResBodyProcessorErrorMsg
+	ResBodyProcessorErrorMsg
 )
diff --git a/internal/variables/variablesmap.gen.go b/internal/variables/variablesmap.gen.go
index 227e00d..c7291b8 100644
--- a/internal/variables/variablesmap.gen.go
+++ b/internal/variables/variablesmap.gen.go
@@ -128,6 +128,8 @@ func (v RuleVariable) Name() string {
 		return "REQUEST_HEADERS"
 	case ResponseHeaders:
 		return "RESPONSE_HEADERS"
+	case ResBodyProcessor:
+		return "RES_BODY_PROCESSOR"
 	case Geo:
 		return "GEO"
 	case RequestCookiesNames:
@@ -198,6 +200,14 @@ func (v RuleVariable) Name() string {
 		return "USERID"
 	case IP:
 		return "IP"
+	case ResBodyError:
+		return "RES_BODY_ERROR"
+	case ResBodyErrorMsg:
+		return "RES_BODY_ERROR_MSG"
+	case ResBodyProcessorError:
+		return "RES_BODY_PROCESSOR_ERROR"
+	case ResBodyProcessorErrorMsg:
+		return "RES_BODY_PROCESSOR_ERROR_MSG"
 
 	default:
 		return "INVALID_VARIABLE"
@@ -262,6 +272,7 @@ var rulemapRev = map[string]RuleVariable{
 	"REQUEST_COOKIES":                  RequestCookies,
 	"REQUEST_HEADERS":                  RequestHeaders,
 	"RESPONSE_HEADERS":                 ResponseHeaders,
+	"RES_BODY_PROCESSOR":               ResBodyProcessor,
 	"GEO":                              Geo,
 	"REQUEST_COOKIES_NAMES":            RequestCookiesNames,
 	"FILES_TMPNAMES":                   FilesTmpNames,
@@ -297,6 +308,10 @@ var rulemapRev = map[string]RuleVariable{
 	"SESSIONID":                        Sessionid,
 	"USERID":                           Userid,
 	"IP":                               IP,
+	"RES_BODY_ERROR":                   ResBodyError,
+	"RES_BODY_ERROR_MSG":               ResBodyErrorMsg,
+	"RES_BODY_PROCESSOR_ERROR":         ResBodyProcessorError,
+	"RES_BODY_PROCESSOR_ERROR_MSG":     ResBodyProcessorErrorMsg,
 }
 
 var errUnknownVariable = errors.New("unknown variable")
diff --git a/rules/transaction.go b/rules/transaction.go
index 857bf19..3b0c245 100644
--- a/rules/transaction.go
+++ b/rules/transaction.go
@@ -42,6 +42,8 @@ type TransactionState interface {
 	Capturing() bool // TODO(anuraaga): Only needed in operators?
 	// CaptureField captures a field.
 	CaptureField(idx int, value string)
+
+	LastPhase() types.RulePhase
 }
 
 // TransactionVariables has pointers to all the variables of the transaction
@@ -82,9 +84,11 @@ type TransactionVariables interface {
 	RequestURI() collection.Single
 	RequestURIRaw() collection.Single
 	ResponseBody() collection.Single
+	ResponseArgs() collection.Map
 	ResponseContentLength() collection.Single
 	ResponseProtocol() collection.Single
 	ResponseStatus() collection.Single
+	ResponseBodyProcessor() collection.Single
 	ServerAddr() collection.Single
 	ServerName() collection.Single
 	ServerPort() collection.Single
diff --git a/types/variables/variables.go b/types/variables/variables.go
index defce46..0678e2e 100644
--- a/types/variables/variables.go
+++ b/types/variables/variables.go
@@ -96,6 +96,8 @@ const (
 	ResponseProtocol = variables.ResponseProtocol
 	// ResponseStatus is the status code of the response
 	ResponseStatus = variables.ResponseStatus
+	// ResBodyProcessor contains the name of the response body processor used, no default
+	ResBodyProcessor = variables.ResBodyProcessor
 	// ServerAddr is the address of the server
 	ServerAddr = variables.ServerAddr
 	// ServerName is the name of the server
@@ -186,6 +188,14 @@ const (
 	XML = variables.XML
 	// MultipartPartHeaders contains the multipart headers
 	MultipartPartHeaders = variables.MultipartPartHeaders
+	// ResBodyError is 1 if the response body processor failed
+	ResBodyError = variables.ResBodyError
+	// ResBodyErrorMsg contains the error message if the response body processor failed
+	ResBodyErrorMsg = variables.ResBodyErrorMsg
+	// ResBodyProcessorError is 1 if the response body processor failed
+	ResBodyProcessorError = variables.ResBodyProcessorError
+	// ResBodyProcessorErrorMsg contains the error message if the response body processor failed
+	ResBodyProcessorErrorMsg = variables.ResBodyProcessorErrorMsg
 )
 
 // Parse returns the byte interpretation


Test patch:
diff --git a/actions/ctl_test.go b/actions/ctl_test.go
index 4c5ae7e..eb23264 100644
--- a/actions/ctl_test.go
+++ b/actions/ctl_test.go
@@ -16,169 +16,338 @@ import (
 )
 
 func TestCtl(t *testing.T) {
-	tests := []struct {
-		input string
-		check func(t *testing.T, logEntry string, tx *corazawaf.Transaction)
+	tests := map[string]struct {
+		input     string
+		prepareTX func(tx *corazawaf.Transaction)
+		checkTX   func(t *testing.T, tx *corazawaf.Transaction, logEntry string)
 	}{
-		{
+		"ruleRemoveTargetById": {
 			input: "ruleRemoveTargetById=123",
 		},
-		{
-			input: "ruleRemoveTargetById=A",
-			check: func(t *testing.T, logEntry string, tx *corazawaf.Transaction) {
-				if wantToContain, have := "Invalid range", logEntry; !strings.Contains(have, wantToContain) {
-					t.Errorf("unexpected log entry: want to contain %q, have %q", wantToContain, logEntry)
-				}
-			},
-		},
-		{
+		"ruleRemoveTargetByTag": {
 			input: "ruleRemoveTargetByTag=tag1",
 		},
-		{
+		"ruleRemoveTargetByMsg": {
 			input: "ruleRemoveTargetByMsg=somethingWentWrong",
 		},
-		{
-			input: "auditEngine=Off",
-			check: func(t *testing.T, logEntry string, tx *corazawaf.Transaction) {
-				if tx.AuditEngine != types.AuditEngineOff {
-					t.Error("Failed to set auditEngine")
+		"auditEngine incorrect": {
+			input: "auditEngine=X",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if wantToContain, have := "Invalid status", logEntry; !strings.Contains(have, wantToContain) {
+					t.Errorf("Failed to log entry, want to contain %q, have %q", wantToContain, have)
 				}
 			},
 		},
-		{
-			input: "auditEngine=In",
-			check: func(t *testing.T, logEntry string, tx *corazawaf.Transaction) {
-				if tx.AuditEngine != types.AuditEngineOn {
-					t.Error("Failed to set auditEngine")
-				}
-
-				if wantToContain, have := "invalid audit engine status: In", logEntry; !strings.Contains(have, wantToContain) {
-					t.Errorf("unexpected log entry: want to contain %q, have %q", wantToContain, logEntry)
+		"auditEngine successfully": {
+			input: "auditEngine=Off",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if tx.AuditEngine != types.AuditEngineOff {
+					t.Error("Failed to disable audit log")
 				}
 			},
 		},
-		{
+		"auditLogParts": {
 			input: "auditLogParts=A",
-			check: func(t *testing.T, logEntry string, tx *corazawaf.Transaction) {
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
 				if want, have := types.AuditLogPartAuditLogHeader, tx.AuditLogParts[0]; want != have {
-					t.Errorf("Failed to set auditLogParts, want %s, have %s", string(want), string(have))
+					t.Errorf("Failed to set audit log parts, want %s, have %s", string(want), string(have))
 				}
 			},
 		},
-		{
+		"forceRequestBodyVariable incorrect": {
+			input: "forceRequestBodyVariable=X",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if wantToContain, have := "X", logEntry; !strings.Contains(have, wantToContain) {
+					t.Errorf("Failed to log entry, want to contain %q, have %q", wantToContain, have)
+				}
+			},
+		},
+		"forceRequestBodyVariable successfully": {
 			input: "forceRequestBodyVariable=On",
-			check: func(t *testing.T, logEntry string, tx *corazawaf.Transaction) {
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
 				if want, have := true, tx.ForceRequestBodyVariable; want != have {
 					t.Errorf("Failed to set forceRequestBodyVariable, want %t, have %t", want, have)
 				}
 			},
 		},
-		{
-			input: "forceRequestBodyVariable=In",
-			check: func(t *testing.T, logEntry string, tx *corazawaf.Transaction) {
-				if tx.ForceRequestBodyVariable != false {
-					t.Error("Failed to set forceRequestBodyVariable")
+		"requestBodyAccess incorrect": {
+			input: "requestBodyAccess=X",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if wantToContain, have := "[ERROR] Unknown toggle", logEntry; !strings.Contains(have, wantToContain) {
+					t.Errorf("Failed to log entry, want to contain %q, have %q", wantToContain, have)
 				}
-
-				if wantToContain, have := "Unknown toggle", logEntry; !strings.Contains(have, wantToContain) {
-					t.Errorf("unexpected log entry: want to contain %q, have %q", wantToContain, logEntry)
+			},
+		},
+		"requestBodyAccess too late": {
+			prepareTX: func(tx *corazawaf.Transaction) {
+				tx.ProcessRequestHeaders()
+				_, _ = tx.ProcessRequestBody()
+			},
+			input: "requestBodyAccess=On",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if wantToContain, have := "[WARN] Cannot change request body access after request headers phase", logEntry; !strings.Contains(have, wantToContain) {
+					t.Errorf("Failed to log entry, want to contain %q, have %q", wantToContain, have)
 				}
 			},
 		},
-		{
-			input: "requestBodyAccess=Off",
-			check: func(t *testing.T, logEntry string, tx *corazawaf.Transaction) {
-				if want, have := false, tx.RequestBodyAccess; want != have {
+		"requestBodyAccess successfully": {
+			prepareTX: func(tx *corazawaf.Transaction) {
+				tx.ProcessRequestHeaders()
+			},
+			input: "requestBodyAccess=On",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if want, have := true, tx.RequestBodyAccess; want != have {
 					t.Errorf("Failed to set requestBodyAccess, want %t, have %t", want, have)
 				}
 			},
 		},
-		{
-			input: "requestBodyAccess=In",
-			check: func(t *testing.T, logEntry string, tx *corazawaf.Transaction) {
-				if tx.RequestBodyAccess != false {
-					t.Error("Failed to set requestBodyAccess")
+		"requestBodyProcessor too late": {
+			prepareTX: func(tx *corazawaf.Transaction) {
+				tx.ProcessRequestHeaders()
+				_, _ = tx.ProcessRequestBody()
+			},
+			input: "requestBodyProcessor=JSON",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if wantToContain, have := "[WARN] Cannot change request body processor after request headers phase", logEntry; !strings.Contains(have, wantToContain) {
+					t.Errorf("Failed to log entry, want to contain %q, have %q", wantToContain, have)
 				}
-
-				if wantToContain, have := "Unknown toggle", logEntry; !strings.Contains(have, wantToContain) {
-					t.Errorf("unexpected log entry: want to contain %q, have %q", wantToContain, logEntry)
+			},
+		},
+		"requestBodyProcessor successfully": {
+			prepareTX: func(tx *corazawaf.Transaction) {
+				tx.ProcessRequestHeaders()
+			},
+			input: "requestBodyProcessor=XML",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if want, have := "XML", tx.Variables().RequestBodyProcessor().Get(); want != have {
+					t.Errorf("Failed to set requestBodyProcessor, want %s, have %s", want, have)
 				}
 			},
 		},
-		{
+		"requestBodyLimit incorrect": {
+			input: "requestBodyLimit=X",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if wantToContain, have := "[ERROR] Invalid limit", logEntry; !strings.Contains(have, wantToContain) {
+					t.Errorf("Failed to log entry, want to contain %q, have %q", wantToContain, have)
+				}
+			},
+		},
+		"requestBodyLimit too late": {
+			prepareTX: func(tx *corazawaf.Transaction) {
+				tx.ProcessRequestHeaders()
+				_, _ = tx.ProcessRequestBody()
+			},
 			input: "requestBodyLimit=12345",
-			check: func(t *testing.T, logEntry string, tx *corazawaf.Transaction) {
-				if tx.RequestBodyLimit != 12345 {
-					t.Error("Failed to set request body limit")
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if wantToContain, have := "[WARN] Cannot change request body limit after request headers phase", logEntry; !strings.Contains(have, wantToContain) {
+					t.Errorf("Failed to log entry, want to contain %q, have %q", wantToContain, have)
 				}
 			},
 		},
-		{
+		"requestBodyLimit successfully": {
+			input: "requestBodyLimit=12345",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if want, have := int64(12345), tx.RequestBodyLimit; want != have {
+					t.Errorf("Failed to set requestBodyLimit, want %d, have %d", want, have)
+				}
+			},
+		},
+		"ruleEngine incorrect": {
+			input: "ruleEngine=X",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if wantToContain, have := "Invalid status", logEntry; !strings.Contains(have, wantToContain) {
+					t.Errorf("Failed to log entry, want to contain %q, have %q", wantToContain, have)
+				}
+			},
+		},
+		"ruleEngine successfully": {
 			input: "ruleEngine=Off",
-			check: func(t *testing.T, logEntry string, tx *corazawaf.Transaction) {
-				if tx.RuleEngine != types.RuleEngineOff {
-					t.Errorf("Failed to disable rule engine, got %s", tx.RuleEngine.String())
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if want, have := types.RuleEngineOff, tx.RuleEngine; want != have {
+					t.Errorf("Failed to set ruleEngine, want %s, have %s", want, have)
 				}
 			},
 		},
-		{
+		"ruleRemoveById": {
 			input: "ruleRemoveById=123",
 		},
-		{
+		"ruleRemoveById incorrect": {
+			input: "ruleRemoveById=W",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if wantToContain, have := "[ERROR] Invalid rule ID", logEntry; !strings.Contains(have, wantToContain) {
+					t.Errorf("Failed to log entry, want to contain %q, have %q", wantToContain, have)
+				}
+			},
+		},
+		"ruleRemoveByMsg": {
 			input: "ruleRemoveByMsg=somethingWentWrong",
 		},
-		{
+		"ruleRemoveByTag": {
 			input: "ruleRemoveByTag=tag1",
 		},
-		{
+		"requestBodyProcessor": {
 			input: "requestBodyProcessor=XML",
-			check: func(t *testing.T, logEntry string, tx *corazawaf.Transaction) {
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
 				if want, have := tx.Variables().RequestBodyProcessor().Get(), "XML"; want != have {
 					t.Errorf("failed to set requestBodyProcessor, want %s, have %s", want, have)
 				}
 			},
 		},
-		{
-			input: "debugLogLevel=1",
+		"responseBodyAccess incorrect": {
+			input: "responseBodyAccess=X",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if wantToContain, have := "[ERROR] Unknown toggle", logEntry; !strings.Contains(have, wantToContain) {
+					t.Errorf("Failed to log entry, want to contain %q, have %q", wantToContain, have)
+				}
+			},
 		},
-		{
-			input: "debugLogLevel=A",
-			check: func(t *testing.T, logEntry string, tx *corazawaf.Transaction) {
-				if wantToContain, have := "Invalid log level", logEntry; !strings.Contains(have, wantToContain) {
-					t.Errorf("unexpected log entry: want to contain %q, have %q", wantToContain, logEntry)
+		"responseBodyAccess too late": {
+			prepareTX: func(tx *corazawaf.Transaction) {
+				tx.ProcessRequestHeaders()
+				_, _ = tx.ProcessRequestBody()
+				tx.ProcessResponseHeaders(200, "HTTP/1.1")
+				_, _ = tx.ProcessResponseBody()
+			},
+			input: "responseBodyAccess=On",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if wantToContain, have := "[WARN] Cannot change response body access after response headers phase", logEntry; !strings.Contains(have, wantToContain) {
+					t.Errorf("Failed to log entry, want to contain %q, have %q", wantToContain, have)
+				}
+			},
+		},
+		"responseBodyAccess successfully": {
+			prepareTX: func(tx *corazawaf.Transaction) {
+				tx.ProcessRequestHeaders()
+			},
+			input: "responseBodyAccess=On",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if want, have := true, tx.ResponseBodyAccess; want != have {
+					t.Errorf("Failed to set responseBodyAccess, want %t, have %t", want, have)
+				}
+			},
+		},
+		"responseBodyLimit successfuly": {
+			input: "responseBodyLimit=12345",
+			prepareTX: func(tx *corazawaf.Transaction) {
+				tx.ProcessRequestHeaders()
+				_, _ = tx.ProcessRequestBody()
+				tx.ProcessRequestHeaders()
+			},
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if tx.ResponseBodyLimit != 12345 {
+					t.Errorf("Failed to set response body limit, want %d, have %d", 12345, tx.ResponseBodyLimit)
+				}
+			},
+		},
+		"responseBodyLimit too late": {
+			input: "responseBodyLimit=12345",
+			prepareTX: func(tx *corazawaf.Transaction) {
+				tx.ProcessRequestHeaders()
+				_, _ = tx.ProcessRequestBody()
+				tx.ProcessResponseHeaders(200, "HTTP/1.1")
+				_, _ = tx.ProcessResponseBody()
+			},
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if wantToContain, have := "[WARN] Cannot change response body access after response headers phase", logEntry; !strings.Contains(have, wantToContain) {
+					t.Errorf("Failed to log entry, want to contain %q, have %q", wantToContain, have)
+				}
+			},
+		},
+		"responseBodyLimit incorrect": {
+			input: "responseBodyLimit=a",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if wantToContain, have := "[ERROR] Invalid limit", logEntry; !strings.Contains(have, wantToContain) {
+					t.Errorf("Failed to log entry, want to contain %q, have %q", wantToContain, have)
+				}
+			},
+		},
+		"responseBodyProcessor successfully": {
+			input: "responseBodyProcessor=XML",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if want, have := tx.Variables().ResponseBodyProcessor().Get(), "XML"; want != have {
+					t.Errorf("failed to set requestBodyProcessor, want %s, have %s", want, have)
 				}
 			},
 		},
+		"responseBodyProcessor too late": {
+			prepareTX: func(tx *corazawaf.Transaction) {
+				tx.ProcessRequestHeaders()
+				_, _ = tx.ProcessRequestBody()
+				tx.ProcessResponseHeaders(200, "HTTP/1.1")
+				_, _ = tx.ProcessResponseBody()
+			},
+			input: "responseBodyProcessor=XML",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if wantToContain, have := "[WARN] Cannot change response body access after response headers phase", logEntry; !strings.Contains(have, wantToContain) {
+					t.Errorf("Failed to log entry, want to contain %q, have %q", wantToContain, have)
+				}
+			},
+		},
+		"forceResponseBodyVariable incorrect": {
+			input: "forceResponseBodyVariable=X",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if wantToContain, have := "X", logEntry; !strings.Contains(have, wantToContain) {
+					t.Errorf("Failed to log entry, want to contain %q, have %q", wantToContain, have)
+				}
+			},
+		},
+		"forceResponseBodyVariable successfully": {
+			input: "forceResponseBodyVariable=On",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if want, have := true, tx.ForceResponseBodyVariable; want != have {
+					t.Errorf("Failed to set forceResponseBodyVariable, want %t, have %t", want, have)
+				}
+			},
+		},
+		"debugLogLevel incorrect": {
+			input: "debugLogLevel=X",
+			checkTX: func(t *testing.T, tx *corazawaf.Transaction, logEntry string) {
+				if wantToContain, have := "[ERROR] Invalid log level", logEntry; !strings.Contains(have, wantToContain) {
+					t.Errorf("Failed to log entry, want to contain %q, have %q", wantToContain, have)
+				}
+			},
+		},
+		"debugLogLevel successfully": {
+			input: "debugLogLevel=1",
+		},
 	}
 
-	for _, test := range tests {
-		testName, _, _ := strings.Cut(test.input, "=")
-		t.Run(testName, func(t *testing.T) {
-			logs := &bytes.Buffer{}
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			logsBuf := &bytes.Buffer{}
+			defer logsBuf.Reset()
+
+			logger := debuglog.Default().
+				WithLevel(debuglog.LogLevelWarn).
+				WithOutput(logsBuf)
+
 			waf := corazawaf.NewWAF()
-			waf.Logger = debuglog.Default().WithOutput(logs)
+			waf.Logger = logger
 			r := corazawaf.NewRule()
 			err := waf.Rules.Add(r)
 			if err != nil {
 				t.Fatalf("failed to add rule: %s", err.Error())
 			}
 
-			tx := waf.NewTransaction()
 			a := ctl()
 			if err := a.Init(r, test.input); err != nil {
 				t.Fatalf("failed to init ctl: %s", err.Error())
 			}
 
+			tx := waf.NewTransaction()
+			if test.prepareTX != nil {
+				test.prepareTX(tx)
+			}
 			a.Evaluate(r, tx)
 
-			if test.check == nil {
+			if test.checkTX == nil {
 				// TODO(jcchavezs): for some tests we can't do any assertion
 				// without going too deep into the implementation details.
 				// t.SkipNow() can't be used because tinygo doesn't support it.
 				// https://github.com/tinygo-org/tinygo/blob/release/src/testing/testing.go#L246
 				return
 			} else {
-				test.check(t, logs.String(), tx)
+				test.checkTX(t, tx, logsBuf.String())
 			}
 		})
 	}
@@ -196,6 +365,17 @@ func TestParseCtl(t *testing.T) {
 		}
 	})
 
+	t.Run("malformed ctl", func(t *testing.T) {
+		ctl, _, _, _, err := parseCtl("unknown=")
+		if err == nil {
+			t.Errorf("expected error, got nil")
+		}
+
+		if ctl != ctlUnknown {
+			t.Errorf("expected ctlUnknown, got %d", ctl)
+		}
+	})
+
 	tCases := []struct {
 		input            string
 		expectAction     ctlFunctionType
@@ -205,12 +385,14 @@ func TestParseCtl(t *testing.T) {
 	}{
 		{"auditEngine=On", ctlAuditEngine, "On", variables.Unknown, ""},
 		{"auditLogParts=A", ctlAuditLogParts, "A", variables.Unknown, ""},
-		{"forceRequestBodyVariable=On", ctlForceRequestBodyVariable, "On", variables.Unknown, ""},
 		{"requestBodyAccess=On", ctlRequestBodyAccess, "On", variables.Unknown, ""},
 		{"requestBodyLimit=100", ctlRequestBodyLimit, "100", variables.Unknown, ""},
 		{"requestBodyProcessor=JSON", ctlRequestBodyProcessor, "JSON", variables.Unknown, ""},
+		{"forceRequestBodyVariable=On", ctlForceRequestBodyVariable, "On", variables.Unknown, ""},
 		{"responseBodyAccess=On", ctlResponseBodyAccess, "On", variables.Unknown, ""},
 		{"responseBodyLimit=100", ctlResponseBodyLimit, "100", variables.Unknown, ""},
+		{"responseBodyProcessor=JSON", ctlResponseBodyProcessor, "JSON", variables.Unknown, ""},
+		{"forceResponseBodyVariable=On", ctlForceResponseBodyVariable, "On", variables.Unknown, ""},
 		{"ruleEngine=On", ctlRuleEngine, "On", variables.Unknown, ""},
 		{"ruleRemoveById=1", ctlRuleRemoveByID, "1", variables.Unknown, ""},
 		{"ruleRemoveByMsg=MY_MSG", ctlRuleRemoveByMsg, "MY_MSG", variables.Unknown, ""},
diff --git a/internal/corazawaf/transaction_test.go b/internal/corazawaf/transaction_test.go
index 66f0e24..18921af 100644
--- a/internal/corazawaf/transaction_test.go
+++ b/internal/corazawaf/transaction_test.go
@@ -4,6 +4,7 @@
 package corazawaf
 
 import (
+	"bytes"
 	"fmt"
 	"io"
 	"regexp"
@@ -370,7 +371,7 @@ func TestProcessRequestHeadersDoesNoEvaluationOnEngineOff(t *testing.T) {
 	}
 
 	_ = tx.ProcessRequestHeaders()
-	if tx.LastPhase != 0 { // 0 means no phases have been evaluated
+	if tx.lastPhase != 0 { // 0 means no phases have been evaluated
 		t.Error("unexpected rule evaluation")
 	}
 }
@@ -381,7 +382,7 @@ func TestProcessRequestBodyDoesNoEvaluationOnEngineOff(t *testing.T) {
 	if _, err := tx.ProcessRequestBody(); err != nil {
 		t.Error("failed to process request body")
 	}
-	if tx.LastPhase != 0 {
+	if tx.lastPhase != 0 {
 		t.Error("unexpected rule evaluation")
 	}
 }
@@ -390,7 +391,7 @@ func TestProcessResponseHeadersDoesNoEvaluationOnEngineOff(t *testing.T) {
 	tx := NewWAF().NewTransaction()
 	tx.RuleEngine = types.RuleEngineOff
 	_ = tx.ProcessResponseHeaders(200, "OK")
-	if tx.LastPhase != 0 {
+	if tx.lastPhase != 0 {
 		t.Error("unexpected rule evaluation")
 	}
 }
@@ -401,7 +402,7 @@ func TestProcessResponseBodyDoesNoEvaluationOnEngineOff(t *testing.T) {
 	if _, err := tx.ProcessResponseBody(); err != nil {
 		t.Error("Failed to process response body")
 	}
-	if tx.LastPhase != 0 {
+	if tx.lastPhase != 0 {
 		t.Error("unexpected rule evaluation")
 	}
 }
@@ -410,7 +411,7 @@ func TestProcessLoggingDoesNoEvaluationOnEngineOff(t *testing.T) {
 	tx := NewWAF().NewTransaction()
 	tx.RuleEngine = types.RuleEngineOff
 	tx.ProcessLogging()
-	if tx.LastPhase != 0 {
+	if tx.lastPhase != 0 {
 		t.Error("unexpected rule evaluation")
 	}
 }
@@ -762,7 +763,7 @@ func TestRequestBodyProcessingAlgorithm(t *testing.T) {
 	tx.ForceRequestBodyVariable = true
 	tx.AddRequestHeader("content-type", "text/plain")
 	tx.AddRequestHeader("content-length", "7")
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	if _, err := tx.requestBodyBuffer.Write([]byte("test123")); err != nil {
 		t.Error("Failed to write request body buffer")
 	}
@@ -778,9 +779,9 @@ func TestRequestBodyProcessingAlgorithm(t *testing.T) {
 }
 
 func TestProcessBodiesSkippedIfHeadersPhasesNotReached(t *testing.T) {
-	l := &inspectableLogger{}
+	logBuffer := &bytes.Buffer{}
 	waf := NewWAF()
-	waf.SetDebugLogOutput(l)
+	waf.SetDebugLogOutput(logBuffer)
 	_ = waf.SetDebugLogLevel(debuglog.LogLevelDebug)
 	tx := waf.NewTransaction()
 	tx.RuleEngine = types.RuleEngineOn
@@ -800,17 +801,18 @@ func TestProcessBodiesSkippedIfHeadersPhasesNotReached(t *testing.T) {
 	if it != nil {
 		t.Fatal("Unexpected interruption")
 	}
+	logEntries := strings.Split(strings.TrimSpace(logBuffer.String()), "\n")
 	// At this point we are expecting three log entries:
 	// [0] New transaction log
 	// [1] Anomalous call before request headers evaluation
 	// [2] Anomalous call before response headers evaluation
-	if want, have := 3, len(l.entries); want != have {
+	if want, have := 3, len(logEntries); want != have {
 		t.Fatalf("unexpected number of log entries, want %d, have %d", want, have)
 	}
-	if want, have := "anomalous call before request headers evaluation", l.entries[1]; !strings.Contains(have, want) {
+	if want, have := "anomalous call before request headers evaluation", logEntries[1]; !strings.Contains(have, want) {
 		t.Fatalf("unexpected message, want %q, have %q", want, have)
 	}
-	if want, have := "anomalous call before response headers evaluation", l.entries[2]; !strings.Contains(have, want) {
+	if want, have := "anomalous call before response headers evaluation", logEntries[2]; !strings.Contains(have, want) {
 		t.Fatalf("unexpected message, want %q, have %q", want, have)
 	}
 	if err := tx.Close(); err != nil {
@@ -821,7 +823,6 @@ func TestProcessBodiesSkippedIfHeadersPhasesNotReached(t *testing.T) {
 func TestTxVariables(t *testing.T) {
 	tx := makeTransaction(t)
 	rv := ruleVariableParams{
-		Name:     "REQUEST_HEADERS",
 		Variable: variables.RequestHeaders,
 		KeyStr:   "ho.*",
 		KeyRx:    regexp.MustCompile("ho.*"),
@@ -858,7 +859,6 @@ func TestTxVariables(t *testing.T) {
 func TestTxVariablesExceptions(t *testing.T) {
 	tx := makeTransaction(t)
 	rv := ruleVariableParams{
-		Name:     "REQUEST_HEADERS",
 		Variable: variables.RequestHeaders,
 		KeyStr:   "ho.*",
 		KeyRx:    regexp.MustCompile("ho.*"),
@@ -917,7 +917,9 @@ func TestTxPhase4Magic(t *testing.T) {
 	waf.ResponseBodyMimeTypes = []string{"text/html"}
 	tx := waf.NewTransaction()
 	tx.AddResponseHeader("content-type", "text/html")
-	tx.LastPhase = types.PhaseResponseHeaders
+	tx.ProcessRequestHeaders()
+	_, _ = tx.ProcessRequestBody()
+	tx.ProcessResponseHeaders(200, "HTTP/1.1")
 	if it, _, err := tx.WriteResponseBody([]byte("more bytes")); it != nil || err != nil {
 		t.Error(err)
 	}
@@ -959,7 +961,7 @@ func TestVariablesMatch(t *testing.T) {
 func TestTxReqBodyForce(t *testing.T) {
 	waf := NewWAF()
 	tx := waf.NewTransaction()
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	tx.RequestBodyAccess = true
 	tx.ForceRequestBodyVariable = true
 	if _, err := tx.requestBodyBuffer.Write([]byte("test")); err != nil {
@@ -1002,23 +1004,24 @@ func TestTxProcessConnection(t *testing.T) {
 }
 
 func TestTxSetServerName(t *testing.T) {
-	l := &inspectableLogger{}
+	logBuffer := &bytes.Buffer{}
 
 	waf := NewWAF()
-	waf.SetDebugLogOutput(l)
+	waf.SetDebugLogOutput(logBuffer)
 	_ = waf.SetDebugLogLevel(debuglog.LogLevelWarn)
 
 	tx := waf.NewTransaction()
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.lastPhase = types.PhaseRequestHeaders
 	tx.SetServerName("coraza.io")
 	if tx.variables.serverName.Get() != "coraza.io" {
 		t.Error("failed to set server name")
 	}
-	if want, have := 1, len(l.entries); want != have {
+	logEntries := strings.Split(strings.TrimSpace(logBuffer.String()), "\n")
+	if want, have := 1, len(logEntries); want != have {
 		t.Fatalf("unexpected number of log entries, want %d, have %d", want, have)
 	}
 
-	if want, have := "SetServerName has been called after ProcessRequestHeaders", l.entries[0]; !strings.Contains(have, want) {
+	if want, have := "SetServerName has been called after ProcessRequestHeaders", logEntries[0]; !strings.Contains(have, want) {
 		t.Fatalf("unexpected message, want %q, have %q", want, have)
 	}
 
@@ -1045,7 +1048,6 @@ func TestTxAddArgument(t *testing.T) {
 func TestTxGetField(t *testing.T) {
 	tx := makeTransaction(t)
 	rvp := ruleVariableParams{
-		Name:     "args",
 		Variable: variables.Args,
 	}
 	if f := tx.GetField(rvp); len(f) != 3 {
@@ -1213,25 +1215,11 @@ func BenchmarkMacro(b *testing.B) {
 	}
 }
 
-type inspectableLogger struct {
-	entries []string
-}
-
-func (l *inspectableLogger) Write(p []byte) (n int, err error) {
-	l.entries = append(l.entries, string(p))
-	return len(p), nil
-}
-
-func (l *inspectableLogger) Close() error {
-	l.entries = nil
-	return nil
-}
-
 func TestProcessorsIdempotencyWithAlreadyRaisedInterruption(t *testing.T) {
-	l := &inspectableLogger{}
+	logBuffer := &bytes.Buffer{}
 
 	waf := NewWAF()
-	waf.SetDebugLogOutput(l)
+	waf.SetDebugLogOutput(logBuffer)
 	_ = waf.SetDebugLogLevel(debuglog.LogLevelError)
 
 	expectedInterruption := &types.Interruption{
@@ -1266,6 +1254,8 @@ func TestProcessorsIdempotencyWithAlreadyRaisedInterruption(t *testing.T) {
 
 	for processor, tCase := range testCases {
 		t.Run(processor, func(t *testing.T) {
+			logBuffer.Reset()
+
 			it := tCase(tx)
 			if it == nil {
 				t.Fatal("expected interruption")
@@ -1275,17 +1265,16 @@ func TestProcessorsIdempotencyWithAlreadyRaisedInterruption(t *testing.T) {
 				t.Fatal("unexpected interruption")
 			}
 
-			if want, have := 1, len(l.entries); want != have {
+			logEntries := strings.Split(strings.TrimSpace(logBuffer.String()), "\n")
+			if want, have := 1, len(logEntries); want != have {
 				t.Fatalf("unexpected number of log entries, want %d, have %d", want, have)
 			}
 
 			expectedMessage := fmt.Sprintf("Calling %s but there is a preexisting interruption", processor)
 
-			if want, have := expectedMessage, l.entries[0]; !strings.Contains(have, want) {
+			if want, have := expectedMessage, logEntries[0]; !strings.Contains(have, want) {
 				t.Fatalf("unexpected message, want to contain %q in %q", want, have)
 			}
-
-			l.Close()
 		})
 	}
 }
diff --git a/internal/seclang/rules_test.go b/internal/seclang/rules_test.go
index 898c7e5..29c7462 100644
--- a/internal/seclang/rules_test.go
+++ b/internal/seclang/rules_test.go
@@ -341,7 +341,9 @@ func TestTxIssue147(t *testing.T) {
 	tx.ResponseBodyAccess = true
 	tx.WAF.ResponseBodyMimeTypes = []string{"text/html"}
 	tx.AddResponseHeader("Content-Type", "text/html")
-	tx.LastPhase = types.PhaseResponseHeaders
+	tx.ProcessRequestHeaders()
+	_, _ = tx.ProcessRequestBody()
+	tx.ProcessResponseHeaders(200, "HTTP/1.1")
 
 	if tx.IsResponseBodyProcessable() {
 		if it, _, err := tx.WriteResponseBody([]byte("#!/usr/bin/python")); it != nil || err != nil {
@@ -477,7 +479,7 @@ func TestUnicode(t *testing.T) {
 	}
 
 	tx := waf.NewTransaction()
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	tx.AddPostRequestArgument("var", `ハローワールド`)
 	it, err := tx.ProcessRequestBody()
 	if err != nil {
@@ -595,7 +597,7 @@ func TestArgumentsCaseSensitive(t *testing.T) {
 	}
 
 	tx := waf.NewTransaction()
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	tx.AddPostRequestArgument("Test1", "Xyz")
 	it, err := tx.ProcessRequestBody()
 	if err != nil {
@@ -606,7 +608,7 @@ func TestArgumentsCaseSensitive(t *testing.T) {
 	}
 
 	tx = waf.NewTransaction()
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	tx.AddPostRequestArgument("TEST1", "Xyz")
 	it, err = tx.ProcessRequestBody()
 	if err != nil {
@@ -617,7 +619,7 @@ func TestArgumentsCaseSensitive(t *testing.T) {
 	}
 
 	tx = waf.NewTransaction()
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	tx.AddPostRequestArgument("test1", "Xyz")
 	it, err = tx.ProcessRequestBody()
 	if err != nil {
@@ -628,7 +630,7 @@ func TestArgumentsCaseSensitive(t *testing.T) {
 	}
 
 	tx = waf.NewTransaction()
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	tx.AddPostRequestArgument("test1", "xyz")
 	it, err = tx.ProcessRequestBody()
 	if err != nil {
@@ -639,7 +641,7 @@ func TestArgumentsCaseSensitive(t *testing.T) {
 	}
 
 	tx = waf.NewTransaction()
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	tx.AddPostRequestArgument("test1", "XYZ")
 	it, err = tx.ProcessRequestBody()
 	if err != nil {
@@ -663,7 +665,7 @@ func TestCookiesCaseSensitive(t *testing.T) {
 
 	tx := waf.NewTransaction()
 	tx.AddRequestHeader("cookie", "Test1=Xyz")
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	it, err := tx.ProcessRequestBody()
 	if err != nil {
 		t.Error(err)
@@ -674,7 +676,7 @@ func TestCookiesCaseSensitive(t *testing.T) {
 
 	tx = waf.NewTransaction()
 	tx.AddRequestHeader("cookie", "TEST1=Xyz")
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	it, err = tx.ProcessRequestBody()
 	if err != nil {
 		t.Error(err)
@@ -685,7 +687,7 @@ func TestCookiesCaseSensitive(t *testing.T) {
 
 	tx = waf.NewTransaction()
 	tx.AddRequestHeader("cookie", "test1=Xyz")
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	it, err = tx.ProcessRequestBody()
 	if err != nil {
 		t.Error(err)
@@ -696,7 +698,7 @@ func TestCookiesCaseSensitive(t *testing.T) {
 
 	tx = waf.NewTransaction()
 	tx.AddRequestHeader("cookie", "test1=xyz")
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	it, err = tx.ProcessRequestBody()
 	if err != nil {
 		t.Error(err)
@@ -707,7 +709,7 @@ func TestCookiesCaseSensitive(t *testing.T) {
 
 	tx = waf.NewTransaction()
 	tx.AddRequestHeader("cookie", "test1=XYZ")
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	it, err = tx.ProcessRequestBody()
 	if err != nil {
 		t.Error(err)
@@ -730,7 +732,7 @@ func TestHeadersCaseSensitive(t *testing.T) {
 
 	tx := waf.NewTransaction()
 	tx.AddRequestHeader("Test1", "Xyz")
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	it, err := tx.ProcessRequestBody()
 	if err != nil {
 		t.Error(err)
@@ -741,7 +743,7 @@ func TestHeadersCaseSensitive(t *testing.T) {
 
 	tx = waf.NewTransaction()
 	tx.AddRequestHeader("TEST1", "Xyz")
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	it, err = tx.ProcessRequestBody()
 	if err != nil {
 		t.Error(err)
@@ -752,7 +754,7 @@ func TestHeadersCaseSensitive(t *testing.T) {
 
 	tx = waf.NewTransaction()
 	tx.AddRequestHeader("test1", "Xyz")
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	it, err = tx.ProcessRequestBody()
 	if err != nil {
 		t.Error(err)
@@ -763,7 +765,7 @@ func TestHeadersCaseSensitive(t *testing.T) {
 
 	tx = waf.NewTransaction()
 	tx.AddRequestHeader("test1", "xyz")
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	it, err = tx.ProcessRequestBody()
 	if err != nil {
 		t.Error(err)
@@ -774,7 +776,7 @@ func TestHeadersCaseSensitive(t *testing.T) {
 
 	tx = waf.NewTransaction()
 	tx.AddRequestHeader("test1", "XYZ")
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	it, err = tx.ProcessRequestBody()
 	if err != nil {
 		t.Error(err)
@@ -796,7 +798,7 @@ func TestParameterPollution(t *testing.T) {
 	}
 
 	tx := waf.NewTransaction()
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	tx.AddPostRequestArgument("test1", "xyz")
 	tx.AddPostRequestArgument("Test1", "Xyz")
 	tx.AddPostRequestArgument("TEST1", "XYZ")
@@ -816,7 +818,7 @@ func TestParameterPollution(t *testing.T) {
 	}
 
 	tx = waf.NewTransaction()
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	tx.AddPostRequestArgument("test1", "xyz")
 	tx.AddPostRequestArgument("Test1", "Xyz")
 	tx.AddPostRequestArgument("tesT1", "Xyz")
@@ -850,7 +852,7 @@ func TestURIQueryParamCaseSensitive(t *testing.T) {
 
 	tx := waf.NewTransaction()
 	tx.ProcessURI("/url?Test1='SQLI", "POST", "HTTP/1.1")
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	_, err = tx.ProcessRequestBody()
 	if err != nil {
 		t.Error(err)
@@ -871,7 +873,7 @@ func TestURIQueryParamCaseSensitive(t *testing.T) {
 
 	tx = waf.NewTransaction()
 	tx.ProcessURI("/test?test1='SQLI&Test1='SQLI&TEST1='SQLI", "POST", "HTTP/1.1")
-	tx.LastPhase = types.PhaseRequestHeaders
+	tx.ProcessRequestHeaders()
 	_, err = tx.ProcessRequestBody()
 	if err != nil {
 		t.Error(err)
diff --git a/testing/coraza_test.go b/testing/coraza_test.go
index 37c9650..054330a 100644
--- a/testing/coraza_test.go
+++ b/testing/coraza_test.go
@@ -9,6 +9,7 @@ import (
 	"testing"
 
 	"github.com/corazawaf/coraza/v3"
+	"github.com/corazawaf/coraza/v3/debuglog"
 	_ "github.com/corazawaf/coraza/v3/testing/engine"
 	"github.com/corazawaf/coraza/v3/testing/profile"
 )
@@ -21,7 +22,7 @@ func TestEngine(t *testing.T) {
 	t.Logf("Loading %d profiles\n", len(profile.Profiles))
 	for _, p := range profile.Profiles {
 		t.Run(p.Meta.Name, func(t *testing.T) {
-			tt, err := testList(&p)
+			tt, err := testList(t, &p)
 			if err != nil {
 				t.Error(err)
 			}
@@ -57,14 +58,19 @@ func TestEngine(t *testing.T) {
 	}
 }
 
-func testList(p *profile.Profile) ([]*Test, error) {
+func testList(t *testing.T, p *profile.Profile) ([]*Test, error) {
+	t.Helper()
+	logger := debuglog.Default().
+		WithLevel(debuglog.LogLevelDebug).
+		WithOutput(testLogOutput{t})
 	var tests []*Test
-	for _, t := range p.Tests {
-		name := t.Title
-		for _, stage := range t.Stages {
+	for _, test := range p.Tests {
+		name := test.Title
+		for _, stage := range test.Stages {
 			w, err := coraza.NewWAF(coraza.NewWAFConfig().
 				WithRootFS(os.DirFS("testdata")).
-				WithDirectives(p.Rules))
+				WithDirectives(p.Rules).
+				WithDebugLogger(logger))
 			if err != nil {
 				return nil, err
 			}
diff --git a/testing/engine/json.go b/testing/engine/json.go
index aa29a66..0848261 100644
--- a/testing/engine/json.go
+++ b/testing/engine/json.go
@@ -10,7 +10,7 @@ import (
 var _ = profile.RegisterProfile(profile.Profile{
 	Meta: profile.Meta{
 		Author:      "jptosso",
-		Description: "Test if the json request body work",
+		Description: "Test if the JSON request/response body work",
 		Enabled:     true,
 		Name:        "jsonyaml",
 	},
@@ -35,12 +35,18 @@ var _ = profile.RegisterProfile(profile.Profile{
 								1100,
 								1101,
 								1010,
+								1000,
+								1011,
 							},
 							NonTriggeredRules: []int{
 								1111,
 								1102,
 								103,
 							},
+							Headers: map[string]string{
+								"Content-Type": "application/json",
+							},
+							Data: `{"test":123, "test2": 456, "test3": [22, 44, 55], "test4": 3}`,
 						},
 					},
 				},
@@ -49,7 +55,10 @@ var _ = profile.RegisterProfile(profile.Profile{
 	},
 	Rules: `
 SecRequestBodyAccess On
+SecResponseBodyAccess On
+SecResponseBodyMimeType application/json
 SecRule REQUEST_HEADERS:content-type "application/json" "id: 100, phase:1, pass, log, ctl:requestBodyProcessor=JSON"
+SecRule RESPONSE_HEADERS:content-type "application/json" "id:1000, phase:3, pass, log, ctl:responseBodyProcessor=JSON"
 SecRule REQBODY_PROCESSOR "JSON" "id: 101,phase:2,log,block"
 
 SecRule REQBODY_ERROR "!@eq 0" "id:1111, phase:2, log, block"
@@ -62,5 +71,6 @@ SecRule ARGS:json.test3.2 "@eq 55" "id:1101, phase:2, log, block"
 SecRule ARGS:json.test "@eq 456" "id:1102, phase:2, log, block"
 
 SecRule ARGS:json.test3 "@eq 3" "id: 1010, phase:2, log, block"
+SecRule RESPONSE_ARGS:json.test4 "@eq 3" "id: 1011, phase:4, log, block"
 `,
 })
diff --git a/testing/logger_test.go b/testing/logger_test.go
new file mode 100644
index 0000000..b6b457b
--- /dev/null
+++ b/testing/logger_test.go
@@ -0,0 +1,17 @@
+// Copyright 2023 Juan Pablo Tosso and the OWASP Coraza contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package testing
+
+import (
+	"testing"
+)
+
+type testLogOutput struct {
+	t *testing.T
+}
+
+func (l testLogOutput) Write(p []byte) (int, error) {
+	l.t.Log(string(p))
+	return len(p), nil
+}