Repository: coreruleset/go-ftw
Pull request #209 (2023-11-11 21:15:21)

feat: update to newest ftw-tests-schema
- add new `retry_once` option
- add test case for `retry_once`
- make test infrastructure more modular
- improvements to CLI
- support for platform overrides
- removal of deprecated options and obsolete code

fzipi: @theseion Looks like we have conflicts on this one...
theseion: Depends on the new scheme changes.
theseion: Requires https://github.com/coreruleset/ftw-tests-schema/pull/7
theseion: I think this is ready. It currently uses a fixed commit from `ftw-tests-schema`, so that we can fix the schema, if necessary, before we tag a new version.

This PR has grown quite a bit because many refactorings came in handy and I opted to adjust the schema on-the-fly where I thought it made sense.

There's a draft PR for CRS where I've made all the necessary changes to support the code here: https://github.com/coreruleset/coreruleset/pull/3369. With that PR, all tests for both nginx and httpd succeed (including all previously disabled and commented tests).

Note that Coraza support will come a bit later with https://github.com/coreruleset/coreruleset/pull/3377.

It would be great if I could also get feedback from @jcchavezs and @christiantreutler-avi on how these changes will affect you.

@jcchavezs Note that I opted to drop the `replace` for `koanf/maps` because it is annoying. If that's a problem for Coraza, let me know.
fzipi: Well, honestly, is too big to understand cross implications between things. I'll approve and 🤞 

Patch:
diff --git a/check/base.go b/check/base.go
index 91f208c..5b48ae1 100644
--- a/check/base.go
+++ b/check/base.go
@@ -6,6 +6,7 @@ package check
 import (
 	"bytes"
 
+	schema "github.com/coreruleset/ftw-tests-schema/types"
 	"github.com/coreruleset/go-ftw/config"
 	"github.com/coreruleset/go-ftw/test"
 	"github.com/coreruleset/go-ftw/waflog"
@@ -39,8 +40,8 @@ func (c *FTWCheck) SetExpectTestOutput(t *test.Output) {
 }
 
 // SetExpectStatus sets to expect the HTTP status from the test to be in the integer range passed
-func (c *FTWCheck) SetExpectStatus(s []int) {
-	c.expected.Status = s
+func (c *FTWCheck) SetExpectStatus(status int) {
+	c.expected.Status = status
 }
 
 // SetExpectResponse sets the response we expect in the text from the server
@@ -54,39 +55,43 @@ func (c *FTWCheck) SetExpectError(expect bool) {
 }
 
 // SetLogContains sets the string to look for in logs
-func (c *FTWCheck) SetLogContains(contains string) {
-	c.expected.LogContains = contains
+func (c *FTWCheck) SetLogContains(regex string) {
+	//nolint:staticcheck
+	c.expected.LogContains = regex
+	c.expected.Log.MatchRegex = regex
 }
 
 // SetNoLogContains sets the string to look that should not present in logs
-func (c *FTWCheck) SetNoLogContains(contains string) {
-	c.expected.NoLogContains = contains
+func (c *FTWCheck) SetNoLogContains(regex string) {
+	//nolint:staticcheck
+	c.expected.NoLogContains = regex
+	c.expected.Log.NoMatchRegex = regex
 }
 
-// ForcedIgnore check if this id need to be ignored from results
-func (c *FTWCheck) ForcedIgnore(id string) bool {
+// ForcedIgnore check if this ID need to be ignored from results
+func (c *FTWCheck) ForcedIgnore(testCase *schema.Test) bool {
 	for re := range c.cfg.TestOverride.Ignore {
-		if re.MatchString(id) {
+		if re.MatchString(testCase.IdString()) {
 			return true
 		}
 	}
 	return false
 }
 
-// ForcedPass check if this id need to be ignored from results
-func (c *FTWCheck) ForcedPass(id string) bool {
+// ForcedPass check if this ID need to be ignored from results
+func (c *FTWCheck) ForcedPass(testCase *schema.Test) bool {
 	for re := range c.cfg.TestOverride.ForcePass {
-		if re.MatchString(id) {
+		if re.MatchString(testCase.IdString()) {
 			return true
 		}
 	}
 	return false
 }
 
-// ForcedFail check if this id need to be ignored from results
-func (c *FTWCheck) ForcedFail(id string) bool {
+// ForcedFail check if this ID need to be ignored from results
+func (c *FTWCheck) ForcedFail(testCase *schema.Test) bool {
 	for re := range c.cfg.TestOverride.ForceFail {
-		if re.MatchString(id) {
+		if re.MatchString(testCase.IdString()) {
 			return true
 		}
 	}
@@ -98,20 +103,6 @@ func (c *FTWCheck) CloudMode() bool {
 	return c.cfg.RunMode == config.CloudRunMode
 }
 
-// SetCloudMode alters the values for expected logs and status code
-func (c *FTWCheck) SetCloudMode() {
-	var status = c.expected.Status
-
-	if c.expected.LogContains != "" {
-		status = append(status, 403)
-		c.expected.LogContains = ""
-	} else if c.expected.NoLogContains != "" {
-		status = append(status, 200, 404, 405)
-		c.expected.NoLogContains = ""
-	}
-	c.expected.Status = status
-}
-
 // SetStartMarker sets the log line that marks the start of the logs to analyze
 func (c *FTWCheck) SetStartMarker(marker []byte) {
 	c.log.StartMarker = bytes.ToLower(marker)
diff --git a/check/error.go b/check/error.go
index a85dbb7..57e042b 100644
--- a/check/error.go
+++ b/check/error.go
@@ -6,14 +6,15 @@ package check
 import "github.com/rs/zerolog/log"
 
 // AssertExpectError helper to check if this error was expected or not
-func (c *FTWCheck) AssertExpectError(err error) bool {
-	if err != nil {
-		log.Debug().Msgf("ftw/check: expected error? -> %t, and error is %s", *c.expected.ExpectError, err.Error())
+func (c *FTWCheck) AssertExpectError(err error) (bool, bool) {
+	errorExpected := c.expected.ExpectError != nil && *c.expected.ExpectError
+	var errorString string
+	if err == nil {
+		errorString = "-"
 	} else {
-		log.Debug().Msgf("ftw/check: expected error? -> %t, and error is nil", *c.expected.ExpectError)
+		errorString = err.Error()
 	}
-	if *c.expected.ExpectError && err != nil {
-		return true
-	}
-	return false
+	log.Debug().Caller().Msgf("Error expected: %t. Found: %s", errorExpected, errorString)
+
+	return errorExpected, (errorExpected && err != nil) || (!errorExpected && err == nil)
 }
diff --git a/check/logs.go b/check/logs.go
index ab1d4ea..d13dba8 100644
--- a/check/logs.go
+++ b/check/logs.go
@@ -3,28 +3,76 @@
 
 package check
 
-// AssertNoLogContains returns true is the string is not found in the logs
-func (c *FTWCheck) AssertNoLogContains() bool {
-	if c.expected.NoLogContains != "" {
-		return !c.log.Contains(c.expected.NoLogContains)
+import (
+	"fmt"
+	"strings"
+
+	"github.com/rs/zerolog/log"
+)
+
+func (c *FTWCheck) AssertLogs() bool {
+	if c.CloudMode() {
+		// No logs to check in cloud mode
+		return true
 	}
-	return false
+
+	return c.assertLogContains() && c.assertNoLogContains()
 }
 
-// NoLogContainsRequired checks that the test requires no_log_contains
-func (c *FTWCheck) NoLogContainsRequired() bool {
-	return c.expected.NoLogContains != ""
+// AssertNoLogContains returns true is the string is not found in the logs
+func (c *FTWCheck) assertNoLogContains() bool {
+	logExpectations := c.expected.Log
+	result := true
+	if logExpectations.NoMatchRegex != "" {
+		result = !c.log.Contains(logExpectations.NoMatchRegex)
+		if !result {
+			log.Debug().Msgf("Unexpectedly found match for '%s'", logExpectations.NoMatchRegex)
+		}
+	}
+	if result && len(logExpectations.NoExpectIds) > 0 {
+		result = !c.log.Contains(generateIdRegex(logExpectations.NoExpectIds))
+		if !result {
+			log.Debug().Msg("Unexpectedly found IDs")
+		}
+	}
+	return result
 }
 
 // AssertLogContains returns true when the logs contain the string
-func (c *FTWCheck) AssertLogContains() bool {
-	if c.expected.LogContains != "" {
-		return c.log.Contains(c.expected.LogContains)
+func (c *FTWCheck) assertLogContains() bool {
+	logExpectations := c.expected.Log
+	result := true
+	if logExpectations.MatchRegex != "" {
+		result = c.log.Contains(logExpectations.MatchRegex)
+		if !result {
+			log.Debug().Msgf("Failed to find match for match_regex. Expected to find '%s'", logExpectations.MatchRegex)
+		}
+	}
+	if result && len(logExpectations.ExpectIds) > 0 {
+		result = c.log.Contains(generateIdRegex(logExpectations.ExpectIds))
+		if !result {
+			log.Debug().Msg("Failed to find expected IDs")
+		}
 	}
-	return false
+	return result
 }
 
-// LogContainsRequired checks that the test requires log_contains
-func (c *FTWCheck) LogContainsRequired() bool {
-	return c.expected.LogContains != ""
+// Search for both standard ModSecurity, and JSON output
+func generateIdRegex(ids []uint) string {
+	modSecLogSyntax := strings.Builder{}
+	jsonLogSyntax := strings.Builder{}
+	modSecLogSyntax.WriteString(`\[id "(?:`)
+	jsonLogSyntax.WriteString(`"id":\s*"?(?:`)
+	for index, id := range ids {
+		if index > 0 {
+			modSecLogSyntax.WriteRune('|')
+			jsonLogSyntax.WriteRune('|')
+		}
+		modSecLogSyntax.WriteString(fmt.Sprint(id))
+		jsonLogSyntax.WriteString(fmt.Sprint(id))
+	}
+	modSecLogSyntax.WriteString(`)"\]`)
+	jsonLogSyntax.WriteString(`)"?`)
+
+	return modSecLogSyntax.String() + "|" + jsonLogSyntax.String()
 }
diff --git a/check/response.go b/check/response.go
index 6f38bbc..ce06457 100644
--- a/check/response.go
+++ b/check/response.go
@@ -4,18 +4,22 @@
 package check
 
 import (
-	"strings"
+	"regexp"
+
+	"github.com/rs/zerolog/log"
 )
 
 // AssertResponseContains checks that the http response contains the needle
 func (c *FTWCheck) AssertResponseContains(response string) bool {
 	if c.expected.ResponseContains != "" {
-		return strings.Contains(response, c.expected.ResponseContains)
+		found, err := regexp.MatchString(c.expected.ResponseContains, response)
+		if err != nil {
+			log.Fatal().Msgf("Invalid regular expression for matching response contents: '%s'", c.expected.ResponseContains)
+		}
+		if !found {
+			log.Debug().Msgf("Failed to match response contents. Expected to find '%s'", c.expected.ResponseContains)
+		}
+		return found
 	}
-	return false
-}
-
-// ResponseContainsRequired checks that the test requires to check the response
-func (c *FTWCheck) ResponseContainsRequired() bool {
-	return c.expected.ResponseContains != ""
+	return true
 }
diff --git a/check/status.go b/check/status.go
index 8196f4b..8682a3e 100644
--- a/check/status.go
+++ b/check/status.go
@@ -3,17 +3,44 @@
 
 package check
 
+import (
+	"slices"
+
+	"github.com/rs/zerolog/log"
+)
+
+var negativeExpectedStatuses = []int{200, 404, 405}
+
 // AssertStatus will match the expected status list with the one received in the response
 func (c *FTWCheck) AssertStatus(status int) bool {
-	for _, i := range c.expected.Status {
-		if i == status {
-			return true
-		}
+	// No status code expectation defined
+	if c.expected.Status == 0 {
+		return true
 	}
-	return false
+
+	if c.CloudMode() {
+		return c.assertCloudStatus(status)
+	}
+
+	found := c.expected.Status == status
+	if !found {
+		log.Debug().Msgf("Failed to match response status. Expected: %d, found: %d", c.expected.Status, status)
+	}
+	return found
+
 }
 
-// StatusCodeRequired checks that the test requires to check the returned status code
-func (c *FTWCheck) StatusCodeRequired() bool {
-	return c.expected.Status != nil
+func (c *FTWCheck) assertCloudStatus(status int) bool {
+	logExpectations := c.expected.Log
+	if (logExpectations.MatchRegex != "" || len(logExpectations.ExpectIds) > 0) && status == 403 {
+		return true
+	}
+	if (logExpectations.NoMatchRegex != "" || len(logExpectations.NoExpectIds) > 0) && slices.Contains(negativeExpectedStatuses, status) {
+		return true
+	}
+	found := c.expected.Status == status
+	if !found {
+		log.Debug().Msgf("Failed to match response status (cloud mode). Expected: %d, found: %d", c.expected.Status, status)
+	}
+	return found
 }
diff --git a/cmd/root.go b/cmd/root.go
index 779b2bd..6ad5d11 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -14,10 +14,11 @@ import (
 )
 
 var (
-	cfgFile string
-	debug   bool
-	trace   bool
-	cloud   bool
+	cfgFile       string
+	overridesFile string
+	debug         bool
+	trace         bool
+	cloud         bool
 )
 
 var cfg = config.NewDefaultConfig()
@@ -28,7 +29,8 @@ func NewRootCommand() *cobra.Command {
 		Use:   "ftw run",
 		Short: "Framework for Testing WAFs - Go Version",
 	}
-	rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "override config file (default is $PWD/.ftw.yaml)")
+	rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "specify config file (default is $PWD/.ftw.yaml)")
+	rootCmd.PersistentFlags().StringVar(&overridesFile, "overrides", "", "specify file with platform specific overrides")
 	rootCmd.PersistentFlags().BoolVarP(&debug, "debug", "", false, "debug output")
 	rootCmd.PersistentFlags().BoolVarP(&trace, "trace", "", false, "trace output: really, really verbose")
 	rootCmd.PersistentFlags().BoolVarP(&cloud, "cloud", "", false, "cloud mode: rely only on HTTP status codes for determining test success or failure (will not process any logs)")
@@ -71,5 +73,8 @@ func initConfig() {
 	if cloud {
 		cfg.RunMode = config.CloudRunMode
 	}
-
+	err = cfg.LoadPlatformOverrides(overridesFile)
+	if err != nil {
+		log.Fatal("failed to load platform overrides")
+	}
 }
diff --git a/cmd/run.go b/cmd/run.go
index 590b5ae..0c4551e 100644
--- a/cmd/run.go
+++ b/cmd/run.go
@@ -33,14 +33,14 @@ func NewRunCommand() *cobra.Command {
 
 	runCmd.Flags().StringP("exclude", "e", "", "exclude tests matching this Go regular expression (e.g. to exclude all tests beginning with \"91\", use \"^91.*\"). \nIf you want more permanent exclusion, check the 'exclude' option in the config file.")
 	runCmd.Flags().StringP("include", "i", "", "include only tests matching this Go regular expression (e.g. to include only tests beginning with \"91\", use \"^91.*\"). \\nIf you want more permanent inclusion, check the 'include' option in the config file.\"")
-	_ = runCmd.Flags().MarkDeprecated("id", "This flag will be removed in v2.0. Use --include matching your test only.")
 	runCmd.Flags().StringP("dir", "d", ".", "recursively find yaml tests in this directory")
 	runCmd.Flags().StringP("output", "o", "normal", "output type for ftw tests. \"normal\" is the default.")
 	runCmd.Flags().StringP("file", "f", "", "output file path for ftw tests. Prints to standard output by default.")
+	runCmd.Flags().StringP("log-file", "l", "", "path to log file to watch for WAF events")
 	runCmd.Flags().BoolP("time", "t", false, "show time spent per test")
 	runCmd.Flags().BoolP("show-failures-only", "", false, "shows only the results of failed tests")
 	runCmd.Flags().Duration("connect-timeout", 3*time.Second, "timeout for connecting to endpoints during test execution")
-	runCmd.Flags().Duration("read-timeout", 1*time.Second, "timeout for receiving responses during test execution")
+	runCmd.Flags().Duration("read-timeout", 10*time.Second, "timeout for receiving responses during test execution")
 	runCmd.Flags().Int("max-marker-retries", 20, "maximum number of times the search for log markers will be repeated.\nEach time an additional request is sent to the web server, eventually forcing the log to be flushed")
 	runCmd.Flags().Int("max-marker-log-lines", 500, "maximum number of lines to search for a marker before aborting")
 	runCmd.Flags().String("wait-for-host", "", "Wait for host to be available before running tests.")
@@ -55,23 +55,26 @@ func NewRunCommand() *cobra.Command {
 	runCmd.Flags().Bool("wait-for-insecure-skip-tls-verify", http.DefaultInsecureSkipTLSVerify, "Skips tls certificate checks for the HTTPS request.")
 	runCmd.Flags().Bool("wait-for-no-redirect", http.DefaultNoRedirect, "Do not follow HTTP 3xx redirects.")
 	runCmd.Flags().DurationP("rate-limit", "r", 0, "Limit the request rate to the server to 1 request per specified duration. 0 is the default, and disables rate limiting.")
+	runCmd.Flags().Bool("fail-fast", false, "Fail on first failed test")
 
 	return runCmd
 }
 
+//gocyclo:ignore
 func runE(cmd *cobra.Command, _ []string) error {
 	cmd.SilenceUsage = true
 	exclude, _ := cmd.Flags().GetString("exclude")
 	include, _ := cmd.Flags().GetString("include")
 	dir, _ := cmd.Flags().GetString("dir")
 	outputFilename, _ := cmd.Flags().GetString("file")
+	logFilePath, _ := cmd.Flags().GetString("log-file")
 	showTime, _ := cmd.Flags().GetBool("time")
 	showOnlyFailed, _ := cmd.Flags().GetBool("show-failures-only")
 	wantedOutput, _ := cmd.Flags().GetString("output")
 	connectTimeout, _ := cmd.Flags().GetDuration("connect-timeout")
 	readTimeout, _ := cmd.Flags().GetDuration("read-timeout")
-	maxMarkerRetries, _ := cmd.Flags().GetInt("max-marker-retries")
-	maxMarkerLogLines, _ := cmd.Flags().GetInt("max-marker-log-lines")
+	maxMarkerRetries, _ := cmd.Flags().GetUint("max-marker-retries")
+	maxMarkerLogLines, _ := cmd.Flags().GetUint("max-marker-log-lines")
 	// wait4x flags
 	waitForHost, _ := cmd.Flags().GetString("wait-for-host")
 	timeout, _ := cmd.Flags().GetDuration("wait-for-timeout")
@@ -85,6 +88,7 @@ func runE(cmd *cobra.Command, _ []string) error {
 	insecureSkipTLSVerify, _ := cmd.Flags().GetBool("wait-for-insecure-skip-tls-verify")
 	noRedirect, _ := cmd.Flags().GetBool("wait-for-no-redirect")
 	rateLimit, _ := cmd.Flags().GetDuration("rate-limit")
+	failFast, _ := cmd.Flags().GetBool("fail-fast")
 
 	if exclude != "" && include != "" {
 		cmd.SilenceUsage = false
@@ -96,6 +100,10 @@ func runE(cmd *cobra.Command, _ []string) error {
 	if maxMarkerLogLines != 0 {
 		cfg.WithMaxMarkerLogLines(maxMarkerLogLines)
 	}
+	if logFilePath != "" {
+		cfg.LogFile = logFilePath
+	}
+
 	files := fmt.Sprintf("%s/**/*.yaml", dir)
 	tests, err := test.GetTestsFromFiles(files)
 
@@ -164,6 +172,7 @@ func runE(cmd *cobra.Command, _ []string) error {
 		ConnectTimeout: connectTimeout,
 		ReadTimeout:    readTimeout,
 		RateLimit:      rateLimit,
+		FailFast:       failFast,
 	}, out)
 
 	if err != nil {
diff --git a/config/config.go b/config/config.go
index 9d27745..cda1ae7 100644
--- a/config/config.go
+++ b/config/config.go
@@ -4,21 +4,24 @@
 package config
 
 import (
+	"fmt"
 	"os"
 	"strings"
 
+	schema "github.com/coreruleset/ftw-tests-schema/types/overrides"
 	"github.com/knadh/koanf/parsers/yaml"
 	"github.com/knadh/koanf/providers/env"
 	"github.com/knadh/koanf/providers/file"
 	"github.com/knadh/koanf/providers/rawbytes"
 	koanfv2 "github.com/knadh/koanf/v2"
+	"github.com/rs/zerolog/log"
 )
 
 // NewDefaultConfig initializes the configuration with default values
 func NewDefaultConfig() *FTWConfiguration {
 	cfg := &FTWConfiguration{
 		LogFile:             "",
-		TestOverride:        FTWTestOverride{},
+		PlatformOverrides:   PlatformOverrides{},
 		LogMarkerHeaderName: DefaultLogMarkerHeaderName,
 		RunMode:             DefaultRunMode,
 		MaxMarkerRetries:    DefaultMaxMarkerRetries,
@@ -70,7 +73,7 @@ func NewConfigFromFile(cfgFile string) (*FTWConfiguration, error) {
 		return nil, err
 	}
 
-	return unmarshal(k)
+	return unmarshalConfig(k)
 }
 
 // NewConfigFromEnv reads configuration information from environment variables that start with `FTW_`
@@ -85,7 +88,7 @@ func NewConfigFromEnv() (*FTWConfiguration, error) {
 		return nil, err
 	}
 
-	return unmarshal(k)
+	return unmarshalConfig(k)
 }
 
 // NewConfigFromString initializes the configuration from a yaml formatted string. Useful for testing.
@@ -96,7 +99,52 @@ func NewConfigFromString(conf string) (*FTWConfiguration, error) {
 		return nil, err
 	}
 
-	return unmarshal(k)
+	return unmarshalConfig(k)
+}
+
+// LoadPlatformOverrides loads platform overrides from the specified file path
+func (c *FTWConfiguration) LoadPlatformOverrides(overridesFile string) error {
+	if overridesFile == "" {
+		log.Trace().Msg("No overrides file specified, skipping.")
+		return nil
+	}
+	if _, err := os.Stat(overridesFile); err != nil {
+		return fmt.Errorf("could not find overrides file '%s'", overridesFile)
+	}
+
+	log.Debug().Msgf("Loading platform overrides from '%s'", overridesFile)
+
+	k := getKoanfInstance()
+	err := k.Load(file.Provider(overridesFile), yaml.Parser())
+	if err != nil {
+		return err
+	}
+
+	overrides, err := unmarshalPlatformOverrides(k)
+	if err != nil {
+		return err
+	}
+
+	c.PlatformOverrides.FTWOverrides = *overrides
+	c.populatePlatformOverridesMap()
+
+	return nil
+}
+
+func (c *FTWConfiguration) populatePlatformOverridesMap() {
+	rulesMap := map[uint][]*schema.TestOverride{}
+	for i := 0; i < len(c.PlatformOverrides.TestOverrides); i++ {
+		testOverride := &c.PlatformOverrides.TestOverrides[i]
+		var list []*schema.TestOverride
+		list, ok := rulesMap[testOverride.RuleId]
+		if !ok {
+			list = []*schema.TestOverride{}
+		}
+		list = append(list, testOverride)
+		rulesMap[testOverride.RuleId] = list
+
+	}
+	c.PlatformOverrides.OverridesMap = rulesMap
 }
 
 // WithLogfile changes the logfile in the configuration.
@@ -120,17 +168,17 @@ func (c *FTWConfiguration) WithLogMarkerHeaderName(name string) {
 }
 
 // WithMaxMarkerRetries sets the new amount of retries we are doing to find markers in the logfile.
-func (c *FTWConfiguration) WithMaxMarkerRetries(retries int) {
+func (c *FTWConfiguration) WithMaxMarkerRetries(retries uint) {
 	c.MaxMarkerRetries = retries
 }
 
 // WithMaxMarkerLogLines sets the new amount of lines we go back in the logfile attempting to find markers.
-func (c *FTWConfiguration) WithMaxMarkerLogLines(amount int) {
+func (c *FTWConfiguration) WithMaxMarkerLogLines(amount uint) {
 	c.MaxMarkerLogLines = amount
 }
 
 // Unmarshal the loaded koanf instance into a configuration object
-func unmarshal(k *koanfv2.Koanf) (*FTWConfiguration, error) {
+func unmarshalConfig(k *koanfv2.Koanf) (*FTWConfiguration, error) {
 	config := NewDefaultConfig()
 	err := k.UnmarshalWithConf("", config, koanfv2.UnmarshalConf{Tag: "koanf"})
 	if err != nil {
@@ -140,6 +188,17 @@ func unmarshal(k *koanfv2.Koanf) (*FTWConfiguration, error) {
 	return config, nil
 }
 
+// Unmarshal the loaded koanf instance into an FTWOverrides object
+func unmarshalPlatformOverrides(k *koanfv2.Koanf) (*schema.FTWOverrides, error) {
+	overrides := &schema.FTWOverrides{}
+	err := k.UnmarshalWithConf("", overrides, koanfv2.UnmarshalConf{Tag: "yaml"})
+	if err != nil {
+		return nil, err
+	}
+
+	return overrides, nil
+}
+
 // Get the global koanf instance
 func getKoanfInstance() *koanfv2.Koanf {
 	// Use "." as the key path delimiter. This can be "/" or any character.
diff --git a/config/types.go b/config/types.go
index be000c6..d5ebeca 100644
--- a/config/types.go
+++ b/config/types.go
@@ -7,7 +7,8 @@ import (
 	"fmt"
 	"regexp"
 
-	"github.com/coreruleset/go-ftw/test"
+	schema "github.com/coreruleset/ftw-tests-schema/types/overrides"
+	"github.com/coreruleset/go-ftw/ftwhttp"
 )
 
 // RunMode represents the mode of the test run
@@ -21,15 +22,17 @@ const (
 	// DefaultLogMarkerHeaderName is the default log marker header name
 	DefaultLogMarkerHeaderName string = "X-CRS-Test"
 	// DefaultMaxMarkerRetries is the default amount of retries that will be attempted to find the log markers
-	DefaultMaxMarkerRetries int = 20
+	DefaultMaxMarkerRetries uint = 20
 	// DefaultMaxMarkerLogLines is the default lines we are going read back in a logfile to find the markers
-	DefaultMaxMarkerLogLines int = 500
+	DefaultMaxMarkerLogLines uint = 500
 )
 
 // FTWConfiguration FTW global Configuration
 type FTWConfiguration struct {
 	// Logfile is the path to the file that contains the WAF logs to check. The path may be absolute or relative, in which case it will be interpreted as relative to the current working directory.
 	LogFile string `koanf:"logfile"`
+	// PlatformOverrides holds platform specific overrides for tests in the test suite
+	PlatformOverrides PlatformOverrides `koanf:"platformoverrides"`
 	// TestOverride holds the test overrides that will apply globally
 	TestOverride FTWTestOverride `koanf:"testoverride"`
 	// LogMarkerHeaderName is the name of the header that will be used by the test framework to mark positions in the log file
@@ -37,15 +40,20 @@ type FTWConfiguration struct {
 	// RunMode stores the mode used to interpret test results. See https://github.com/coreruleset/go-ftw#%EF%B8%8F-cloud-mode.
 	RunMode RunMode `koanf:"mode"`
 	// MaxMarkerRetries is the maximum number of times the search for log markers will be repeated; each time an additional request is sent to the web server, eventually forcing the log to be flushed
-	MaxMarkerRetries int `koanf:"maxmarkerretries"`
+	MaxMarkerRetries uint `koanf:"maxmarkerretries"`
 	// MaxMarkerLogLines is the maximum number of lines to search for a marker before aborting
-	MaxMarkerLogLines int `koanf:"maxmarkerloglines"`
+	MaxMarkerLogLines uint `koanf:"maxmarkerloglines"`
 	// IncludeTests is a list of tests to include (same as --include)
 	IncludeTests map[*FTWRegexp]string `koanf:"include"`
 	// ExcludeTests is a list of tests to exclude (same as --exclude)
 	ExcludeTests map[*FTWRegexp]string `koanf:"exclude"`
 }
 
+type PlatformOverrides struct {
+	schema.FTWOverrides
+	OverridesMap map[uint][]*schema.TestOverride
+}
+
 // FTWTestOverride holds four lists:
 //
 //	Overrides allows you to override input parameters in tests. An example usage is if you want to change the `dest_addr` of all tests to point to an external IP or host.
@@ -53,12 +61,31 @@ type FTWConfiguration struct {
 //	ForcePass is for tests you want to pass unconditionally. You should add a comment on why you force to pass the test
 //	ForceFail is for tests you want to fail unconditionally. You should add a comment on why you force to fail the test
 type FTWTestOverride struct {
-	Overrides test.Overrides        `koanf:"input"`
+	Overrides Overrides             `koanf:"input"`
 	Ignore    map[*FTWRegexp]string `koanf:"ignore"`
 	ForcePass map[*FTWRegexp]string `koanf:"forcepass"`
 	ForceFail map[*FTWRegexp]string `koanf:"forcefail"`
 }
 
+// Overrides represents the overridden inputs that have to be applied to tests
+type Overrides struct {
+	DestAddr   *string        `yaml:"dest_addr,omitempty" koanf:"dest_addr,omitempty"`
+	Port       *int           `yaml:"port,omitempty" koanf:"port,omitempty"`
+	Protocol   *string        `yaml:"protocol,omitempty" koanf:"protocol,omitempty"`
+	URI        *string        `yaml:"uri,omitempty" koanf:"uri,omitempty"`
+	Version    *string        `yaml:"version,omitempty" koanf:"version,omitempty"`
+	Headers    ftwhttp.Header `yaml:"headers,omitempty" koanf:"headers,omitempty"`
+	Method     *string        `yaml:"method,omitempty" koanf:"method,omitempty"`
+	Data       *string        `yaml:"data,omitempty" koanf:"data,omitempty"`
+	SaveCookie *bool          `yaml:"save_cookie,omitempty" koanf:"save_cookie,omitempty"`
+	// Deprecated: replaced with AutocompleteHeaders
+	StopMagic               *bool   `yaml:"stop_magic" koanf:"stop_magic,omitempty"`
+	AutocompleteHeaders     *bool   `yaml:"autocomplete_headers" koanf:"autocomplete_headers,omitempty"`
+	EncodedRequest          *string `yaml:"encoded_request,omitempty" koanf:"encoded_request,omitempty"`
+	RAWRequest              *string `yaml:"raw_request,omitempty" koanf:"raw_request,omitempty"`
+	OverrideEmptyHostHeader *bool   `yaml:"override_empty_host_header,omitempty" koanf:"override_empty_host_header,omitempty"`
+}
+
 // FTWRegexp is a wrapper around regexp.Regexp that implements the Unmarshaler interface
 type FTWRegexp regexp.Regexp
 
diff --git a/ftwhttp/client.go b/ftwhttp/client.go
index 8558ff2..e4b7832 100644
--- a/ftwhttp/client.go
+++ b/ftwhttp/client.go
@@ -8,12 +8,13 @@ import (
 	"crypto/tls"
 	"crypto/x509"
 	"fmt"
-	"golang.org/x/time/rate"
 	"net"
 	"net/http/cookiejar"
 	"strings"
 	"time"
 
+	"golang.org/x/time/rate"
+
 	"github.com/rs/zerolog/log"
 	"golang.org/x/net/publicsuffix"
 )
diff --git a/ftwhttp/connection.go b/ftwhttp/connection.go
index ac9bd5e..557cbe8 100644
--- a/ftwhttp/connection.go
+++ b/ftwhttp/connection.go
@@ -119,7 +119,7 @@ func (c *Connection) Response() (*Response, error) {
 	}
 
 	data := buf.Bytes()
-	log.Trace().Msgf("ftw/http: received data - %q", data)
+	log.Debug().Msgf("ftw/http: received data - %q", data)
 
 	response := Response{
 		RAW:    data,
diff --git a/ftwhttp/request.go b/ftwhttp/request.go
index 4d8b497..4ba1dde 100644
--- a/ftwhttp/request.go
+++ b/ftwhttp/request.go
@@ -91,9 +91,6 @@ func (r Request) RawData() []byte {
 
 // Headers return request headers
 func (r Request) Headers() Header {
-	if r.headers == nil {
-		return nil
-	}
 	return r.headers
 }
 
diff --git a/go.mod b/go.mod
index 7cb47c3..4d5d1cf 100644
--- a/go.mod
+++ b/go.mod
@@ -4,7 +4,7 @@ go 1.21
 
 require (
 	github.com/Masterminds/sprig v2.22.0+incompatible
-	github.com/coreruleset/ftw-tests-schema v1.1.0
+	github.com/coreruleset/ftw-tests-schema v1.1.1-0.20240509130026-48cda85f1d5f
 	github.com/go-logr/zerologr v1.2.3
 	github.com/goccy/go-yaml v1.9.2
 	github.com/google/uuid v1.6.0
@@ -56,5 +56,3 @@ require (
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
-
-replace github.com/knadh/koanf/maps => github.com/knadh/koanf/maps v0.1.1
diff --git a/go.sum b/go.sum
index 7a2e381..40b31e7 100644
--- a/go.sum
+++ b/go.sum
@@ -9,8 +9,8 @@ github.com/antchfx/htmlquery v1.3.0/go.mod h1:zKPDVTMhfOmcwxheXUsx4rKJy8KEY/PU6e
 github.com/antchfx/xpath v1.2.3 h1:CCZWOzv5bAqjVv0offZ2LVgVYFbeldKQVuLNbViZdes=
 github.com/antchfx/xpath v1.2.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
 github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
-github.com/coreruleset/ftw-tests-schema v1.1.0 h1:3+NYrdLE3HVmOc3nGrisRBBvY9lGjePUrV+YkT5Ay3s=
-github.com/coreruleset/ftw-tests-schema v1.1.0/go.mod h1:gRd9wBxjUI85HypWRDxJzbk1JqHC4KTxl0l/Y2p9QK4=
+github.com/coreruleset/ftw-tests-schema v1.1.1-0.20240509130026-48cda85f1d5f h1:lncLdzrrw40U6m5C26lQ7T/EYzzWIlQonnb3JYSaKPE=
+github.com/coreruleset/ftw-tests-schema v1.1.1-0.20240509130026-48cda85f1d5f/go.mod h1:stf2FA6YhkPEMq30FzKvs97Qn5P9F7LHqWQbNGvGhNk=
 github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -42,8 +42,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4=
 github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
-github.com/icza/backscanner v0.0.0-20240221180818-f23e3ba0e79f h1:EKPpaKkARuHjoV/ZKzk3vqbSJXULRSivDCQhL+tF77Y=
-github.com/icza/backscanner v0.0.0-20240221180818-f23e3ba0e79f/go.mod h1:GYeBD1CF7AqnKZK+UCytLcY3G+UKo0ByXX/3xfdNyqQ=
 github.com/icza/backscanner v0.0.0-20240328210400-b40c3a86dec5 h1:FcxwOojw6pUiPpsf7Q6Fw/pI+7cR6FlapLBEGV/902A=
 github.com/icza/backscanner v0.0.0-20240328210400-b40c3a86dec5/go.mod h1:GYeBD1CF7AqnKZK+UCytLcY3G+UKo0ByXX/3xfdNyqQ=
 github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
@@ -62,8 +60,6 @@ github.com/knadh/koanf/providers/file v0.1.0 h1:fs6U7nrV58d3CFAFh8VTde8TM262ObYf
 github.com/knadh/koanf/providers/file v0.1.0/go.mod h1:rjJ/nHQl64iYCtAW2QQnF0eSmDEX/YZ/eNFj5yR6BvA=
 github.com/knadh/koanf/providers/rawbytes v0.1.0 h1:dpzgu2KO6uf6oCb4aP05KDmKmAmI51k5pe8RYKQ0qME=
 github.com/knadh/koanf/providers/rawbytes v0.1.0/go.mod h1:mMTB1/IcJ/yE++A2iEZbY1MLygX7vttU+C+S/YmPu9c=
-github.com/knadh/koanf/v2 v2.1.0 h1:eh4QmHHBuU8BybfIJ8mB8K8gsGCD/AUQTdwGq/GzId8=
-github.com/knadh/koanf/v2 v2.1.0/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es=
 github.com/knadh/koanf/v2 v2.1.1 h1:/R8eXqasSTsmDCsAyYj+81Wteg8AqrV9CP6gvsTsOmM=
 github.com/knadh/koanf/v2 v2.1.1/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es=
 github.com/kyokomi/emoji/v2 v2.2.12 h1:sSVA5nH9ebR3Zji1o31wu3yOwD1zKXQA2z0zUyeit60=
@@ -117,8 +113,6 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
-golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
 golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
 golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -127,8 +121,6 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
-golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
-golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
 golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
 golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -146,8 +138,6 @@ golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
-golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
 golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
diff --git a/output/output.go b/output/output.go
index fef9604..bbfc0b7 100644
--- a/output/output.go
+++ b/output/output.go
@@ -30,19 +30,19 @@ type catalog map[string]string
 // this catalog is used to translate text from basic terminals to enhanced ones that support emoji, just
 // because we are fancy. If we are not using a normal output, then just use the key from this map.
 var normalCatalog = catalog{
-	"** Starting tests!":                    ":hammer_and_wrench: Starting tests!",
+	"** Starting tests!":                    ":hammer_and_wrench:Starting tests!",
 	"** Running go-ftw!":                    ":rocket:Running go-ftw!",
 	"=> executing tests in file %s":         ":point_right:executing tests in file %s",
 	"+ passed in %s (RTT %s)":               ":check_mark:passed in %s (RTT %s)",
-	"- failed in %s (RTT %s)":               ":collision:failed in %s (RTT %s)",
+	"- %s failed in %s (RTT %s)":            ":collision:%s failed in %s (RTT %s)",
 	"= test ignored":                        ":information:test ignored",
 	"= test forced to fail":                 ":information:test forced to fail",
 	"= test forced to pass":                 ":information:test forced to pass",
 	"¯\\_(ツ)_/¯ No tests were run":          ":person_shrugging:No tests were run",
 	"+ run %d total tests in %s":            ":plus:run %d total tests in %s",
-	">> skipped %d tests":                   ":next_track_button: skipped %d tests",
-	"^ ignored %d tests":                    ":index_pointing_up: ignored %d tests",
-	"^ forced to pass %d tests":             ":index_pointing_up: forced to pass %d tests",
+	">> skipped %d tests":                   ":next_track_button:skipped %d tests",
+	"^ ignored %d tests":                    ":index_pointing_up:ignored %d tests",
+	"^ forced to pass %d tests":             ":index_pointing_up:forced to pass %d tests",
 	"\\o/ All tests successful!":            ":tada:All tests successful!",
 	"- %d test(s) failed to run: %+q":       ":thumbs_down:%d test(s) failed to run: %+q",
 	"- %d test(s) were forced to fail: %+q": ":index_pointing_up:%d test(s) were forced to fail: %+q",
diff --git a/runner/run.go b/runner/run.go
index d893757..6d6836f 100644
--- a/runner/run.go
+++ b/runner/run.go
@@ -6,14 +6,15 @@ package runner
 import (
 	"errors"
 	"fmt"
-	"golang.org/x/time/rate"
 	"regexp"
 	"time"
 
+	"golang.org/x/time/rate"
+
 	"github.com/google/uuid"
 	"github.com/rs/zerolog/log"
 
-	"github.com/coreruleset/ftw-tests-schema/types"
+	schema "github.com/coreruleset/ftw-tests-schema/types"
 	"github.com/coreruleset/go-ftw/check"
 	"github.com/coreruleset/go-ftw/config"
 	"github.com/coreruleset/go-ftw/ftwhttp"
@@ -51,6 +52,7 @@ func Run(cfg *config.FTWConfiguration, tests []*test.FTWTest, c RunnerConfig, ou
 
 	runContext := &TestRunContext{
 		Config:         cfg,
+		RunnerConfig:   &c,
 		Include:        c.Include,
 		Exclude:        c.Exclude,
 		ShowTime:       c.ShowTime,
@@ -65,6 +67,9 @@ func Run(cfg *config.FTWConfiguration, tests []*test.FTWTest, c RunnerConfig, ou
 		if err := RunTest(runContext, tc); err != nil {
 			return &TestRunContext{}, err
 		}
+		if c.FailFast && runContext.Stats.TotalFailed() > 0 {
+			break
+		}
 	}
 
 	runContext.Stats.printSummary(out)
@@ -81,14 +86,13 @@ func RunTest(runContext *TestRunContext, ftwTest *test.FTWTest) error {
 	changed := true
 
 	for _, testCase := range ftwTest.Tests {
-		// if we received a particular testid, skip until we find it
-		if needToSkipTest(runContext.Include, runContext.Exclude, testCase.TestTitle, *ftwTest.Meta.Enabled) {
-			runContext.Stats.addResultToStats(Skipped, testCase.TestTitle, 0)
-			if !*ftwTest.Meta.Enabled && !runContext.ShowOnlyFailed {
-				runContext.Output.Println("\tskipping %s - (enabled: false) in file.", testCase.TestTitle)
-			}
+		// if we received a particular test ID, skip until we find it
+		if needToSkipTest(runContext.Include, runContext.Exclude, &testCase) {
+			runContext.Stats.addResultToStats(Skipped, &testCase)
+			log.Trace().Msgf("\tskipping %s", testCase.IdString())
 			continue
 		}
+		test.ApplyPlatformOverrides(runContext.Config, &testCase)
 		// this is just for printing once the next test
 		if changed && !runContext.ShowOnlyFailed {
 			runContext.Output.Println(runContext.Output.Message("=> executing tests in file %s"), ftwTest.Meta.Name)
@@ -96,7 +100,7 @@ func RunTest(runContext *TestRunContext, ftwTest *test.FTWTest) error {
 		}
 
 		if !runContext.ShowOnlyFailed {
-			runContext.Output.Printf("\trunning %s: ", testCase.TestTitle)
+			runContext.Output.Printf("\trunning %s: ", testCase.IdString())
 		}
 		// Iterate over stages
 		for _, stage := range testCase.Stages {
@@ -104,10 +108,21 @@ func RunTest(runContext *TestRunContext, ftwTest *test.FTWTest) error {
 			if err != nil {
 				return err
 			}
-			if err := RunStage(runContext, ftwCheck, testCase, stage.SD); err != nil {
-				return err
+			if err := RunStage(runContext, ftwCheck, testCase, stage); err != nil {
+				if err.Error() == "retry-once" {
+					log.Info().Msgf("Retrying test once: %s", testCase.IdString())
+					if err = RunStage(runContext, ftwCheck, testCase, stage); err != nil {
+						return err
+					}
+				} else {
+					return err
+				}
 			}
 		}
+		runContext.Stats.addResultToStats(runContext.Result, &testCase)
+		if runContext.RunnerConfig.FailFast && runContext.Stats.TotalFailed() > 0 {
+			break
+		}
 	}
 
 	return nil
@@ -118,12 +133,14 @@ func RunTest(runContext *TestRunContext, ftwTest *test.FTWTest) error {
 // ftwCheck is the current check utility
 // testCase is the test case the stage belongs to
 // stage is the stage you want to run
-func RunStage(runContext *TestRunContext, ftwCheck *check.FTWCheck, testCase types.Test, stage types.StageData) error {
+//
+//gocyclo:ignore
+func RunStage(runContext *TestRunContext, ftwCheck *check.FTWCheck, testCase schema.Test, stage schema.Stage) error {
 	stageStartTime := time.Now()
 	stageID := uuid.NewString()
 	// Apply global overrides initially
 	testInput := (test.Input)(stage.Input)
-	test.ApplyInputOverrides(&runContext.Config.TestOverride.Overrides, &testInput)
+	test.ApplyInputOverrides(runContext.Config, &testInput)
 	expectedOutput := stage.Output
 	expectErr := false
 	if expectedOutput.ExpectError != nil {
@@ -136,9 +153,9 @@ func RunStage(runContext *TestRunContext, ftwCheck *check.FTWCheck, testCase typ
 	}
 
 	// Do not even run test if result is overridden. Just use the override and display the overridden result.
-	if overridden := overriddenTestResult(ftwCheck, testCase.TestTitle); overridden != Failed {
-		runContext.Stats.addResultToStats(overridden, testCase.TestTitle, 0)
-		displayResult(runContext, overridden, time.Duration(0), time.Duration(0))
+	if overridden := overriddenTestResult(ftwCheck, &testCase); overridden != Failed {
+		runContext.Stats.addResultToStats(overridden, &testCase)
+		displayResult(&testCase, runContext, overridden, time.Duration(0), time.Duration(0))
 		return nil
 	}
 
@@ -189,19 +206,19 @@ func RunStage(runContext *TestRunContext, ftwCheck *check.FTWCheck, testCase typ
 
 	// now get the test result based on output
 	testResult := checkResult(ftwCheck, response, responseErr)
+	if testResult == Failed && expectedOutput.RetryOnce != nil && *expectedOutput.RetryOnce {
+		return errors.New("retry-once")
+	}
 
 	roundTripTime := runContext.Client.GetRoundTripTime().RoundTripDuration()
 	stageTime := time.Since(stageStartTime)
 
-	runContext.Stats.addResultToStats(testResult, testCase.TestTitle, stageTime)
-
 	runContext.Result = testResult
 
 	// show the result unless quiet was passed in the command line
-	displayResult(runContext, testResult, roundTripTime, stageTime)
+	displayResult(&testCase, runContext, testResult, roundTripTime, stageTime)
 
-	runContext.Stats.Run++
-	runContext.Stats.TotalTime += stageTime
+	runContext.Stats.addStageResultToStats(&testCase, stageTime)
 
 	return nil
 }
@@ -244,15 +261,10 @@ func markAndFlush(runContext *TestRunContext, dest *ftwhttp.Destination, stageID
 	return nil, fmt.Errorf("can't find log marker. Am I reading the correct log? Log file: %s", runContext.Config.LogFile)
 }
 
-func needToSkipTest(include *regexp.Regexp, exclude *regexp.Regexp, title string, enabled bool) bool {
-	// skip disabled tests
-	if !enabled {
-		return true
-	}
-
+func needToSkipTest(include *regexp.Regexp, exclude *regexp.Regexp, testCase *schema.Test) bool {
 	// never skip enabled explicit inclusions
 	if include != nil {
-		if include.MatchString(title) {
+		if include.MatchString(testCase.IdString()) {
 			// inclusion always wins over exclusion
 			return false
 		}
@@ -262,7 +274,7 @@ func needToSkipTest(include *regexp.Regexp, exclude *regexp.Regexp, title string
 	// if we need to exclude tests, and the title matches,
 	// it needs to be skipped
 	if exclude != nil {
-		if exclude.MatchString(title) {
+		if exclude.MatchString(testCase.IdString()) {
 			result = true
 		}
 	}
@@ -270,7 +282,7 @@ func needToSkipTest(include *regexp.Regexp, exclude *regexp.Regexp, title string
 	// if we need to include tests, but the title does not match
 	// it needs to be skipped
 	if include != nil {
-		if !include.MatchString(title) {
+		if !include.MatchString(testCase.IdString()) {
 			result = true
 		}
 	}
@@ -280,18 +292,20 @@ func needToSkipTest(include *regexp.Regexp, exclude *regexp.Regexp, title string
 
 func checkTestSanity(testInput test.Input) bool {
 	return (utils.IsNotEmpty(testInput.Data) && testInput.EncodedRequest != "") ||
+		//nolint:staticcheck
 		(utils.IsNotEmpty(testInput.Data) && testInput.RAWRequest != "") ||
+		//nolint:staticcheck
 		(testInput.EncodedRequest != "" && testInput.RAWRequest != "")
 }
 
-func displayResult(rc *TestRunContext, result TestResult, roundTripTime time.Duration, stageTime time.Duration) {
+func displayResult(testCase *schema.Test, rc *TestRunContext, result TestResult, roundTripTime time.Duration, stageTime time.Duration) {
 	switch result {
 	case Success:
 		if !rc.ShowOnlyFailed {
 			rc.Output.Println(rc.Output.Message("+ passed in %s (RTT %s)"), stageTime, roundTripTime)
 		}
 	case Failed:
-		rc.Output.Println(rc.Output.Message("- failed in %s (RTT %s)"), stageTime, roundTripTime)
+		rc.Output.Println(rc.Output.Message("- %s failed in %s (RTT %s)"), testCase.IdString(), stageTime, roundTripTime)
 	case Ignored:
 		if !rc.ShowOnlyFailed {
 			rc.Output.Println(rc.Output.Message(":information:test ignored"))
@@ -307,16 +321,16 @@ func displayResult(rc *TestRunContext, result TestResult, roundTripTime time.Dur
 	}
 }
 
-func overriddenTestResult(c *check.FTWCheck, id string) TestResult {
-	if c.ForcedIgnore(id) {
+func overriddenTestResult(c *check.FTWCheck, testCase *schema.Test) TestResult {
+	if c.ForcedIgnore(testCase) {
 		return Ignored
 	}
 
-	if c.ForcedFail(id) {
+	if c.ForcedFail(testCase) {
 		return ForceFail
 	}
 
-	if c.ForcedPass(id) {
+	if c.ForcedPass(testCase) {
 		return ForcePass
 	}
 
@@ -326,35 +340,33 @@ func overriddenTestResult(c *check.FTWCheck, id string) TestResult {
 // checkResult has the logic for verifying the result for the test sent
 func checkResult(c *check.FTWCheck, response *ftwhttp.Response, responseError error) TestResult {
 	// Request might return an error, but it could be expected, we check that first
-	if responseError != nil && c.AssertExpectError(responseError) {
-		return Success
+	if expected, succeeded := c.AssertExpectError(responseError); expected {
+		if succeeded {
+			return Success
+		}
+		return Failed
 	}
 
-	// If there was no error, perform the remaining checks
+	// In case of an unexpected error skip other checks
 	if responseError != nil {
+		log.Debug().Msgf("Encountered unexpected error: %v", responseError)
 		return Failed
 	}
-	if c.CloudMode() {
-		// Cloud mode assumes that we cannot read logs. So we rely entirely on status code and response
-		c.SetCloudMode()
+
+	// We should have a response here
+	if response == nil {
+		log.Error().Msg("No response to check")
+		return Failed
 	}
 
-	// If we didn't expect an error, check the actual response from the waf
-	if response != nil {
-		if c.StatusCodeRequired() && !c.AssertStatus(response.Parsed.StatusCode) {
-			return Failed
-		}
-		// Check if text is contained in the full raw response
-		if c.ResponseContainsRequired() && !c.AssertResponseContains(response.GetFullResponse()) {
-			return Failed
-		}
+	if !c.AssertStatus(response.Parsed.StatusCode) {
+		return Failed
 	}
-	// Lastly, check logs
-	if c.LogContainsRequired() && !c.AssertLogContains() {
+	if !c.AssertResponseContains(response.GetFullResponse()) {
 		return Failed
 	}
-	// We assume that they were already setup, for comparing
-	if c.NoLogContainsRequired() && !c.AssertNoLogContains() {
+	// Lastly, check logs
+	if !c.AssertLogs() {
 		return Failed
 	}
 
diff --git a/runner/stats.go b/runner/stats.go
index b37aab7..1ba34f3 100644
--- a/runner/stats.go
+++ b/runner/stats.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/rs/zerolog/log"
 
+	schema "github.com/coreruleset/ftw-tests-schema/types"
 	"github.com/coreruleset/go-ftw/output"
 )
 
@@ -63,29 +64,33 @@ func (stats *RunStats) TotalFailed() int {
 	return len(stats.Failed) + len(stats.ForcedFail)
 }
 
-func (stats *RunStats) addResultToStats(result TestResult, title string, testTime time.Duration) {
+func (stats *RunStats) addResultToStats(result TestResult, testCase *schema.Test) {
+	title := testCase.IdString()
+	stats.Run++
+
 	switch result {
 	case Success:
 		stats.Success = append(stats.Success, title)
-		stats.RunTime[title] = testTime
 	case Failed:
 		stats.Failed = append(stats.Failed, title)
-		stats.RunTime[title] = testTime
 	case Skipped:
 		stats.Skipped = append(stats.Skipped, title)
 	case Ignored:
 		stats.Ignored = append(stats.Ignored, title)
 	case ForceFail:
 		stats.ForcedFail = append(stats.ForcedFail, title)
-		stats.RunTime[title] = testTime
 	case ForcePass:
 		stats.ForcedPass = append(stats.ForcedPass, title)
-		stats.RunTime[title] = testTime
 	default:
 		log.Info().Msgf("runner/stats: don't know how to handle TestResult %d", result)
 	}
 }
 
+func (stats *RunStats) addStageResultToStats(testCase *schema.Test, stageTime time.Duration) {
+	stats.RunTime[testCase.IdString()] += stageTime
+	stats.TotalTime += stageTime
+}
+
 func (stats *RunStats) printSummary(out *output.Output) {
 	if stats.Run > 0 {
 		if out.IsJson() {
diff --git a/runner/types.go b/runner/types.go
index e6a0127..bbc0d02 100644
--- a/runner/types.go
+++ b/runner/types.go
@@ -31,6 +31,8 @@ type RunnerConfig struct {
 	ReadTimeout time.Duration
 	// RateLimit is the rate limit for requests to the server. 0 is unlimited.
 	RateLimit time.Duration
+	// FailFast determines whether to stop running tests when the first failure is encountered.
+	FailFast bool
 }
 
 // TestRunContext carries information about the current test run.
@@ -38,6 +40,7 @@ type RunnerConfig struct {
 // and results.
 type TestRunContext struct {
 	Config         *config.FTWConfiguration
+	RunnerConfig   *RunnerConfig
 	Include        *regexp.Regexp
 	Exclude        *regexp.Regexp
 	ShowTime       bool
diff --git a/waflog/read.go b/waflog/read.go
index fe2f349..d8095af 100644
--- a/waflog/read.go
+++ b/waflog/read.go
@@ -79,7 +79,7 @@ func (ll *FTWLogLines) getMarkedLines() [][]byte {
 // CheckLogForMarker reads the log file and searches for a marker line.
 // stageID is the ID of the current stage, which is part of the marker line
 // readLimit is the maximum numbers of lines to check
-func (ll *FTWLogLines) CheckLogForMarker(stageID string, readLimit int) []byte {
+func (ll *FTWLogLines) CheckLogForMarker(stageID string, readLimit uint) []byte {
 	offset, err := ll.logFile.Seek(0, io.SeekEnd)
 	if err != nil {
 		log.Error().Caller().Err(err).Msgf("failed to seek end of log file")
@@ -95,7 +95,7 @@ func (ll *FTWLogLines) CheckLogForMarker(stageID string, readLimit int) []byte {
 	crsHeaderBytes := bytes.ToLower([]byte(ll.LogMarkerHeaderName))
 
 	var line []byte
-	lineCounter := 0
+	lineCounter := uint(0)
 	// Look for the header until EOF or `readLimit` lines at most
 	for {
 		if lineCounter > readLimit {


Test patch:
diff --git a/check/base_test.go b/check/base_test.go
index 5d09fca..8d58a27 100644
--- a/check/base_test.go
+++ b/check/base_test.go
@@ -4,11 +4,11 @@
 package check
 
 import (
-	"sort"
 	"testing"
 
 	"github.com/stretchr/testify/suite"
 
+	schema "github.com/coreruleset/ftw-tests-schema/types"
 	"github.com/coreruleset/go-ftw/config"
 	"github.com/coreruleset/go-ftw/test"
 	"github.com/coreruleset/go-ftw/utils"
@@ -60,7 +60,7 @@ func (s *checkBaseTestSuite) TestNewCheck() {
 	}
 
 	to := test.Output{
-		Status:           []int{200},
+		Status:           200,
 		ResponseContains: "",
 		LogContains:      "nothing",
 		NoLogContains:    "",
@@ -72,6 +72,7 @@ func (s *checkBaseTestSuite) TestNewCheck() {
 
 	c.SetNoLogContains("nologcontains")
 
+	//nolint:staticcheck
 	s.Equal(c.expected.NoLogContains, "nologcontains", "Problem setting nologcontains")
 }
 
@@ -79,52 +80,18 @@ func (s *checkBaseTestSuite) TestForced() {
 	c, err := NewCheck(s.cfg)
 	s.Require().NoError(err)
 
-	s.True(c.ForcedIgnore("942200-1"), "Can't find ignored value")
+	s.True(c.ForcedIgnore(&schema.Test{RuleId: 942200, TestId: 1}), "Can't find ignored value")
 
-	s.False(c.ForcedFail("1245"), "Value should not be found")
+	s.False(c.ForcedFail(&schema.Test{RuleId: 12345, TestId: 1}), "Value should not be found")
 
-	s.False(c.ForcedPass("1234"), "Value should not be found")
+	s.False(c.ForcedPass(&schema.Test{RuleId: 12345, TestId: 1}), "Value should not be found")
 
-	s.True(c.ForcedPass("1245"), "Value should be found")
+	s.True(c.ForcedPass(&schema.Test{RuleId: 1245, TestId: 1}), "Value should be found")
 
-	s.True(c.ForcedFail("6789"), "Value should be found")
+	s.True(c.ForcedFail(&schema.Test{RuleId: 6789, TestId: 1}), "Value should be found")
 
 	s.cfg.TestOverride.Ignore = make(map[*config.FTWRegexp]string)
-	s.Falsef(c.ForcedIgnore("anything"), "Should not find ignored value in empty map")
-
-}
-
-func (s *checkBaseTestSuite) TestCloudMode() {
-	c, err := NewCheck(s.cfg)
-	s.Require().NoError(err)
-
-	s.True(c.CloudMode(), "couldn't detect cloud mode")
-
-	status := []int{200, 301}
-	c.SetExpectStatus(status)
-	c.SetLogContains("this text")
-	// this should override logcontains
-	c.SetCloudMode()
-
-	cloudStatus := c.expected.Status
-	sort.Ints(cloudStatus)
-	res := sort.SearchInts(cloudStatus, 403)
-	s.Equalf(2, res, "couldn't find expected 403 status in %#v -> %d", cloudStatus, res)
-
-	c.SetLogContains("")
-	c.SetNoLogContains("no log contains")
-	// this should override logcontains
-	c.SetCloudMode()
-
-	cloudStatus = c.expected.Status
-	sort.Ints(cloudStatus)
-	found := false
-	for _, n := range cloudStatus {
-		if n == 200 {
-			found = true
-		}
-	}
-	s.True(found, "couldn't find expected 200 status")
+	s.Falsef(c.ForcedIgnore(&schema.Test{RuleId: 1234, TestId: 1}), "Should not find ignored value in empty map")
 
 }
 
diff --git a/check/error_test.go b/check/error_test.go
index 889455e..256c9ca 100644
--- a/check/error_test.go
+++ b/check/error_test.go
@@ -46,12 +46,15 @@ func (s *checkErrorTestSuite) SetupTest() {
 	s.Require().NoError(err)
 	s.cfg.WithLogfile(logName)
 }
+
 func (s *checkErrorTestSuite) TestAssertResponseErrorOK() {
 	c, err := NewCheck(s.cfg)
 	s.Require().NoError(err)
 	for _, e := range expectedOKTests {
 		c.SetExpectError(e.expected)
-		s.Equal(e.expected, c.AssertExpectError(e.err))
+		expected, succeeded := c.AssertExpectError(e.err)
+		s.Equal(e.expected, expected)
+		s.True(succeeded)
 	}
 }
 
@@ -61,6 +64,8 @@ func (s *checkErrorTestSuite) TestAssertResponseFail() {
 
 	for _, e := range expectedFailTests {
 		c.SetExpectError(e.expected)
-		s.False(c.AssertExpectError(e.err))
+		expected, succeeded := c.AssertExpectError(e.err)
+		s.Equal(e.expected, expected)
+		s.False(succeeded)
 	}
 }
diff --git a/check/logs_test.go b/check/logs_test.go
index 38e87b2..eb2cd43 100644
--- a/check/logs_test.go
+++ b/check/logs_test.go
@@ -42,27 +42,87 @@ func (s *checkLogsTestSuite) TearDownTest() {
 	err := os.Remove(s.logName)
 	s.Require().NoError(err)
 }
-func (s *checkLogsTestSuite) TestAssertLogContainsOK() {
+
+func (s *checkLogsTestSuite) TestLogContains() {
 	c, err := NewCheck(s.cfg)
 	s.Require().NoError(err)
 
 	c.SetLogContains(`id "920300"`)
-	s.True(c.AssertLogContains(), "did not find expected content 'id \"920300\"'")
+	s.True(c.AssertLogs(), "did not find expected content 'id \"920300\"'")
 
 	c.SetLogContains(`SOMETHING`)
-	s.False(c.AssertLogContains(), "found something that is not there")
-	s.True(c.LogContainsRequired(), "if LogContains is not empty it should return true")
+	s.False(c.AssertLogs(), "found something that is not there")
 
 	c.SetLogContains("")
-	s.False(c.AssertLogContains(), "empty LogContains should return false")
+	s.True(c.AssertLogs(), "empty LogContains should return true")
+}
 
-	c.SetNoLogContains("SOMETHING")
-	s.True(c.AssertNoLogContains(), "found something that is not there")
+func (s *checkLogsTestSuite) TestNoLogContains() {
+	c, err := NewCheck(s.cfg)
+	s.Require().NoError(err)
 
 	c.SetNoLogContains(`id "920300"`)
-	s.False(c.AssertNoLogContains(), "did not find expected content")
+	s.False(c.AssertLogs(), "did not find expected content")
+
+	c.SetNoLogContains("SOMETHING")
+	s.True(c.AssertLogs(), "found something that is not there")
 
 	c.SetNoLogContains("")
-	s.False(c.AssertNoLogContains(), "should return false when empty string is passed")
-	s.False(c.NoLogContainsRequired(), "if NoLogContains is an empty string is passed should return false")
+	s.True(c.AssertLogs(), "should return true when empty string is passed")
+}
+
+func (s *checkLogsTestSuite) TestAssertLogMatchRegex() {
+	c, err := NewCheck(s.cfg)
+	s.Require().NoError(err)
+
+	c.expected.Log.MatchRegex = `id\s"920300"`
+	s.True(c.AssertLogs(), `did not find expected content 'id\s"920300"'`)
+
+	c.expected.Log.MatchRegex = `SOMETHING`
+	s.False(c.AssertLogs(), "found something that is not there")
+
+	c.expected.Log.MatchRegex = ""
+	s.True(c.AssertLogs(), "empty LogContains should return true")
+}
+
+func (s *checkLogsTestSuite) TestAssertLogNoMatchRegex() {
+	c, err := NewCheck(s.cfg)
+	s.Require().NoError(err)
+
+	c.expected.Log.NoMatchRegex = `id\s"920300"`
+	s.False(c.AssertLogs(), `expected to find 'id\s"920300"'`)
+
+	c.expected.Log.NoMatchRegex = `SOMETHING`
+	s.True(c.AssertLogs(), "expected to _not_ find SOMETHING")
+
+	c.expected.Log.NoMatchRegex = ""
+	s.True(c.AssertLogs(), "empty LogContains should return true")
+}
+
+func (s *checkLogsTestSuite) TestAssertLogExpectIds() {
+	c, err := NewCheck(s.cfg)
+	s.Require().NoError(err)
+
+	c.expected.Log.ExpectIds = []uint{920300}
+	s.True(c.AssertLogs(), `did not find expected content 'id\s"920300"'`)
+
+	c.expected.Log.ExpectIds = []uint{123456}
+	s.False(c.AssertLogs(), "found something that is not there")
+
+	c.expected.Log.ExpectIds = []uint{}
+	s.True(c.AssertLogs(), "empty LogContains should return true")
+}
+
+func (s *checkLogsTestSuite) TestAssertLogNoExpectId() {
+	c, err := NewCheck(s.cfg)
+	s.Require().NoError(err)
+
+	c.expected.Log.NoExpectIds = []uint{920300}
+	s.False(c.AssertLogs(), `expected to find 'id\s"920300"'`)
+
+	c.expected.Log.NoExpectIds = []uint{123456}
+	s.True(c.AssertLogs(), "expected to _not_ find SOMETHING")
+
+	c.expected.Log.NoExpectIds = []uint{}
+	s.True(c.AssertLogs(), "empty LogContains should return true")
 }
diff --git a/check/response_test.go b/check/response_test.go
index cb26a8c..99365bc 100644
--- a/check/response_test.go
+++ b/check/response_test.go
@@ -70,11 +70,3 @@ func (s *checkResponseTestSuite) TestAssertResponseTextChecksFullResponseOK() {
 		s.Truef(c.AssertResponseContains(e.response), "unexpected response: %v", e.response)
 	}
 }
-
-func (s *checkResponseTestSuite) TestAssertResponseContainsRequired() {
-	c, err := NewCheck(s.cfg)
-	s.Require().NoError(err)
-	c.SetExpectResponse("")
-	s.False(c.AssertResponseContains(""), "response shouldn't contain text")
-	s.False(c.ResponseContainsRequired(), "response shouldn't contain text")
-}
diff --git a/check/status_test.go b/check/status_test.go
index e450a99..06beb92 100644
--- a/check/status_test.go
+++ b/check/status_test.go
@@ -4,6 +4,7 @@
 package check
 
 import (
+	"slices"
 	"testing"
 
 	"github.com/stretchr/testify/suite"
@@ -12,23 +13,6 @@ import (
 	"github.com/coreruleset/go-ftw/utils"
 )
 
-var statusOKTests = []struct {
-	status         int
-	expectedStatus []int
-}{
-	{400, []int{0, 100, 200, 400}},
-	{400, []int{400}},
-}
-
-var statusFailTests = []struct {
-	status         int
-	expectedStatus []int
-}{
-	{400, []int{0, 100, 200}},
-	{200, []int{400}},
-	{200, []int{0}},
-}
-
 type checkStatusTestSuite struct {
 	suite.Suite
 	cfg *config.FTWConfiguration
@@ -50,26 +34,122 @@ func (s *checkStatusTestSuite) TestStatusOK() {
 	c, err := NewCheck(s.cfg)
 	s.Require().NoError(err)
 
-	for _, expected := range statusOKTests {
-		c.SetExpectStatus(expected.expectedStatus)
-		s.True(c.AssertStatus(expected.status))
-	}
+	c.SetExpectStatus(0)
+	s.checkStatus(c, []int{})
+
+	c.SetExpectStatus(200)
+	s.checkStatus(c, []int{200})
+
+	c.SetExpectStatus(303)
+	s.checkStatus(c, []int{303})
+
+	c.SetExpectStatus(400)
+	s.checkStatus(c, []int{400})
+
+	c.SetExpectStatus(403)
+	s.checkStatus(c, []int{403})
+
+	c.SetExpectStatus(500)
+	s.checkStatus(c, []int{500})
 }
 
-func (s *checkStatusTestSuite) TestStatusFail() {
+// always match since no status expectation set
+func (s *checkStatusTestSuite) TestCloudModePositiveMatch_AlwaysMatch() {
 	c, err := NewCheck(s.cfg)
 	s.Require().NoError(err)
+	c.cfg.RunMode = config.CloudRunMode
+	s.True(c.CloudMode(), "couldn't detect cloud mode")
 
-	for _, expected := range statusFailTests {
-		c.SetExpectStatus(expected.expectedStatus)
-		s.False(c.AssertStatus(expected.status))
-	}
+	// negative regex match set
+	c.SetLogContains("log contains")
+	s.checkStatus(c, []int{})
+
+	// negative regex match and ID negative ID match set
+	c.expected.Log.ExpectIds = []uint{123456}
+	s.checkStatus(c, []int{})
+
+	// negative ID match set
+	c.SetLogContains("")
+	s.checkStatus(c, []int{})
 }
 
-func (s *checkStatusTestSuite) TestStatusCodeRequired() {
+func (s *checkStatusTestSuite) TestCloudModePositiveMatch() {
 	c, err := NewCheck(s.cfg)
 	s.Require().NoError(err)
+	c.cfg.RunMode = config.CloudRunMode
+	s.True(c.CloudMode(), "couldn't detect cloud mode")
+
+	// nothing set
+	c.SetExpectStatus(418) // I'm a teapot
+	s.checkStatus(c, []int{418})
+
+	// regex match set
+	c.SetLogContains("this text")
+	s.checkStatus(c, []int{403, 418})
+
+	// regex match and ID match set
+	c.expected.Log.ExpectIds = []uint{123456}
+	s.checkStatus(c, []int{403, 418})
+
+	// ID match set
+	c.SetLogContains("")
+	s.checkStatus(c, []int{403, 418})
+}
 
-	c.SetExpectStatus([]int{200})
-	s.True(c.StatusCodeRequired(), "status code should be required")
+// always match since no status expectation set
+func (s *checkStatusTestSuite) TestCloudModeNegativeMatch_AlwaysMatch() {
+	c, err := NewCheck(s.cfg)
+	s.Require().NoError(err)
+	c.cfg.RunMode = config.CloudRunMode
+	s.True(c.CloudMode(), "couldn't detect cloud mode")
+
+	// negative regex match set
+	c.SetNoLogContains("no log contains")
+	s.checkStatus(c, []int{})
+
+	// negative regex match and ID negative ID match set
+	c.expected.Log.NoExpectIds = []uint{123456}
+	s.checkStatus(c, []int{})
+
+	// negative ID match set
+	c.SetNoLogContains("")
+	s.checkStatus(c, []int{})
+}
+
+// status expectation set, only match specific statuses
+func (s *checkStatusTestSuite) TestCloudModeNegativeMatch_SpecificMatch() {
+	c, err := NewCheck(s.cfg)
+	s.Require().NoError(err)
+	c.cfg.RunMode = config.CloudRunMode
+	s.True(c.CloudMode(), "couldn't detect cloud mode")
+
+	// nothing set
+	c.SetExpectStatus(418) // I'm a teapot
+	s.checkStatus(c, []int{418})
+
+	// negative regex match set
+	c.SetNoLogContains("no log contains")
+	s.checkStatus(c, []int{200, 404, 418, 405})
+
+	// negative regex match and ID negative ID match set
+	c.expected.Log.NoExpectIds = []uint{123456}
+	s.checkStatus(c, []int{200, 404, 418, 405})
+
+	// negative ID match set
+	c.SetNoLogContains("")
+	s.checkStatus(c, []int{200, 404, 418, 405})
+}
+
+func (s *checkStatusTestSuite) checkStatus(c *FTWCheck, expectedSuccesses []int) {
+	if len(expectedSuccesses) == 0 {
+		s.True(c.AssertStatus(-1), "Expected successful check because no expectation set")
+		return
+	}
+	for status := 100; status < 600; status++ {
+		if slices.Contains(expectedSuccesses, status) {
+			s.True(c.AssertStatus(status), "Unexpected result for status %d", status)
+		} else {
+			s.False(c.AssertStatus(status), "Unexpected result for status %d", status)
+		}
+	}
 }
diff --git a/cmd/check_test.go b/cmd/check_test.go
index d84ab3c..0ecdc9a 100644
--- a/cmd/check_test.go
+++ b/cmd/check_test.go
@@ -21,22 +21,21 @@ meta:
   description: "Test file for go-ftw"
 tests:
   - # Standard GET request
-    test_title: 1234
+    test_id: 1234
     stages:
-      - stage:
-          input:
-            dest_addr: "127.0.0.1"
-            method: "GET"
-            port: 1234
-            headers:
-              User-Agent: "OWASP CRS test agent"
-              Host: "localhost"
-              Accept: "*/*"
-            protocol: "http"
-            uri: "/"
-            version: "HTTP/1.1"
-          output:
-            status: [200]
+      - input:
+          dest_addr: "127.0.0.1"
+          method: "GET"
+          port: 1234
+          headers:
+            User-Agent: "OWASP CRS test agent"
+            Host: "localhost"
+            Accept: "*/*"
+          protocol: "http"
+          uri: "/"
+          version: "HTTP/1.1"
+        output:
+          status: 200
 `
 
 type checkCmdTestSuite struct {
@@ -46,11 +45,9 @@ type checkCmdTestSuite struct {
 }
 
 func (s *checkCmdTestSuite) SetupTest() {
-	tempDir, err := os.MkdirTemp("", "go-ftw-tests")
-	s.Require().NoError(err)
-	s.tempDir = tempDir
+	s.tempDir = s.T().TempDir()
 
-	err = os.MkdirAll(s.tempDir, fs.ModePerm)
+	err := os.MkdirAll(s.tempDir, fs.ModePerm)
 	s.Require().NoError(err)
 	testFileContents, err := os.CreateTemp(s.tempDir, "mock-test-*.yaml")
 	s.Require().NoError(err)
diff --git a/cmd/run_test.go b/cmd/run_test.go
index e188358..cae1dcd 100644
--- a/cmd/run_test.go
+++ b/cmd/run_test.go
@@ -28,22 +28,21 @@ meta:
   description: "Test file for go-ftw"
 tests:
   - # Standard GET request
-    test_title: 1234
+    test_id: 1234
     stages:
-      - stage:
-          input:
-            dest_addr: "127.0.0.1"
-            method: "GET"
-            port: {{ .Port }}
-            headers:
-              User-Agent: "OWASP CRS test agent"
-              Host: "localhost"
-              Accept: "*/*"
-            protocol: "http"
-            uri: "/"
-            version: "HTTP/1.1"
-          output:
-            status: [200]
+      - input:
+          dest_addr: "127.0.0.1"
+          method: "GET"
+          port: {{ .Port }}
+          headers:
+            User-Agent: "OWASP CRS test agent"
+            Host: "localhost"
+            Accept: "*/*"
+          protocol: "http"
+          uri: "/"
+          version: "HTTP/1.1"
+        output:
+          status: 200
 `
 
 type runCmdTestSuite struct {
@@ -69,12 +68,10 @@ func (s *runCmdTestSuite) setupMockHTTPServer() *httptest.Server {
 }
 
 func (s *runCmdTestSuite) SetupTest() {
-	tempDir, err := os.MkdirTemp("", "go-ftw-tests")
-	s.Require().NoError(err)
-	s.tempDir = tempDir
+	s.tempDir = s.T().TempDir()
 
 	s.testHTTPServer = s.setupMockHTTPServer()
-	err = os.MkdirAll(s.tempDir, fs.ModePerm)
+	err := os.MkdirAll(s.tempDir, fs.ModePerm)
 	s.Require().NoError(err)
 	testUrl, err := url.Parse(s.testHTTPServer.URL)
 	s.Require().NoError(err)
diff --git a/config/config_test.go b/config/config_test.go
index d8d52c4..30900a0 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -10,7 +10,6 @@ import (
 
 	"github.com/stretchr/testify/suite"
 
-	"github.com/coreruleset/go-ftw/test"
 	"github.com/coreruleset/go-ftw/utils"
 )
 
@@ -219,7 +218,7 @@ func (s *fileTestSuite) TestNewDefaultConfigWithParams() {
 	cfg.WithLogfile("mylogfile.log")
 	s.Equal("mylogfile.log", cfg.LogFile)
 	overrides := FTWTestOverride{
-		Overrides: test.Overrides{},
+		Overrides: Overrides{},
 		Ignore:    nil,
 		ForcePass: nil,
 		ForceFail: nil,
@@ -235,8 +234,68 @@ func (s *fileTestSuite) TestNewDefaultConfigWithParams() {
 func (s *baseTestSuite) TestWithMaxMarker() {
 	cfg := NewDefaultConfig()
 	cfg.WithMaxMarkerRetries(19)
-	s.Equal(19, cfg.MaxMarkerRetries)
+	s.Equal(uint(19), cfg.MaxMarkerRetries)
 	cfg.WithMaxMarkerLogLines(111)
-	s.Equal(111, cfg.MaxMarkerLogLines)
+	s.Equal(uint(111), cfg.MaxMarkerLogLines)
 
 }
+
+func (s *baseTestSuite) TestPlatformOverridesDefaults() {
+	overrides := NewDefaultConfig().PlatformOverrides
+	meta := overrides.Meta
+	s.Empty(meta.Annotations)
+	s.Empty(meta.Engine)
+	s.Empty(meta.Platform)
+	s.Empty(overrides.Version)
+	s.Empty(overrides.TestOverrides)
+}
+
+func (s *baseTestSuite) TestLoadPlatformOverrides() {
+	tempDir := s.T().TempDir()
+	overridesFile, err := os.CreateTemp(tempDir, "overrides.yaml")
+	s.Require().NoError(err)
+	_, err = overridesFile.WriteString(`---
+version: "v0.0.0"
+meta:
+  engine: "coraza"
+  platform: "go"
+  annotations:
+    - purpose: "Test loading overrides"
+test_overrides:
+  - rule_id: 920100
+    test_ids: [4, 8]
+    reason: 'Invalid uri, Coraza not reached - 404 page not found'
+    output:
+      status: 404
+      log:
+        match_regex: 'match.*me'
+        no_expect_ids: [1234]
+      response_contains: '404'`)
+
+	s.Require().NoError(err)
+
+	cfg := NewDefaultConfig()
+	err = cfg.LoadPlatformOverrides(overridesFile.Name())
+	s.Require().NoError(err)
+
+	overrides := cfg.PlatformOverrides
+	meta := overrides.Meta
+	s.Equal("v0.0.0", overrides.Version)
+	s.Equal("coraza", meta.Engine)
+	s.Equal("go", meta.Platform)
+	s.Len(meta.Annotations, 1)
+	value, ok := meta.Annotations["purpose"]
+	s.True(ok)
+	s.Equal("Test loading overrides", value)
+
+	s.Len(overrides.TestOverrides, 1)
+	entry := overrides.TestOverrides[0]
+	s.Equal(uint(920100), entry.RuleId)
+	s.ElementsMatch([]uint{4, 8}, entry.TestIds)
+	s.Equal("Invalid uri, Coraza not reached - 404 page not found", entry.Reason)
+	s.Equal(404, entry.Output.Status)
+	s.Equal("match.*me", entry.Output.Log.MatchRegex)
+	s.Len(entry.Output.Log.NoExpectIds, 1)
+	s.Equal(uint(1234), entry.Output.Log.NoExpectIds[0])
+	s.Equal("404", entry.Output.ResponseContains)
+}
diff --git a/ftwhttp/client_test.go b/ftwhttp/client_test.go
index 61f548c..bab928a 100644
--- a/ftwhttp/client_test.go
+++ b/ftwhttp/client_test.go
@@ -6,12 +6,13 @@ package ftwhttp
 import (
 	"bytes"
 	"fmt"
-	"golang.org/x/time/rate"
 	"net/http"
 	"net/http/httptest"
 	"testing"
 	"time"
 
+	"golang.org/x/time/rate"
+
 	"github.com/stretchr/testify/suite"
 )
 
@@ -51,6 +52,13 @@ func (s *clientTestSuite) httpHandler() http.HandlerFunc {
 		} else {
 			w.WriteHeader(http.StatusOK)
 		}
+
+		if r.URL.Path == "/sleep" {
+			duration, err := time.ParseDuration(r.URL.Query().Get("milliseconds") + "ms")
+			s.Require().NoError(err)
+			time.Sleep(duration)
+		}
+
 		resp := new(bytes.Buffer)
 		for key, value := range r.Header {
 			_, err := fmt.Fprintf(resp, "%s=%s,", key, value)
@@ -114,15 +122,13 @@ func (s *clientTestSuite) TestDoRequest() {
 }
 
 func (s *clientTestSuite) TestGetTrackedTime() {
-	d := &Destination{
-		DestAddr: "httpbingo.org",
-		Port:     443,
-		Protocol: "https",
-	}
+	s.httpTestServer(insecureServer)
+	d, err := DestinationFromString(s.ts.URL)
+	s.Require().NoError(err, "This should not error")
 
 	rl := &RequestLine{
 		Method:  "POST",
-		URI:     "/post",
+		URI:     "/sleep?milliseconds=50",
 		Version: "HTTP/1.1",
 	}
 
@@ -131,7 +137,7 @@ func (s *clientTestSuite) TestGetTrackedTime() {
 	data := []byte(`test=me&one=two&one=twice`)
 	req := NewRequest(rl, h, data, true)
 
-	err := s.client.NewConnection(*d)
+	err = s.client.NewConnection(*d)
 	s.Require().NoError(err, "This should not error")
 
 	s.client.StartTrackingTime()
@@ -144,7 +150,7 @@ func (s *clientTestSuite) TestGetTrackedTime() {
 	s.Equal(http.StatusOK, resp.Parsed.StatusCode, "Error in calling website")
 
 	rtt := s.client.GetRoundTripTime()
-	s.GreaterOrEqual(int(rtt.RoundTripDuration()), 0, "Error getting RTT")
+	s.GreaterOrEqual(rtt.RoundTripDuration().Milliseconds(), int64(50), "Error getting RTT")
 }
 
 func (s *clientTestSuite) TestClientMultipartFormDataRequest() {
@@ -250,8 +256,10 @@ func (s *clientTestSuite) TestClientRateLimits() {
 
 	// We need to do at least 2 calls so there is a wait between both.
 	before := time.Now()
-	_, err = s.client.Do(*req)
-	_, err = s.client.Do(*req)
+	//nolint:errcheck
+	s.client.Do(*req)
+	//nolint:errcheck
+	s.client.Do(*req)
 	after := time.Now()
 
 	s.GreaterOrEqual(after.Sub(before), waitTime, "Rate limiter did not work as expected")
diff --git a/runner/run_input_override_test.go b/runner/run_input_override_test.go
index 07a87b6..1710b2f 100644
--- a/runner/run_input_override_test.go
+++ b/runner/run_input_override_test.go
@@ -162,14 +162,14 @@ func (s *inputOverrideTestSuite) TestSetHostFromDestAddr() {
 	}
 	cfg := &config.FTWConfiguration{
 		TestOverride: config.FTWTestOverride{
-			Overrides: test.Overrides{
+			Overrides: config.Overrides{
 				DestAddr:                &overrideHost,
 				OverrideEmptyHostHeader: func() *bool { b := true; return &b }(),
 			},
 		},
 	}
 
-	test.ApplyInputOverrides(&cfg.TestOverride.Overrides, &testInput)
+	test.ApplyInputOverrides(cfg, &testInput)
 
 	s.Equal(overrideHost, *testInput.DestAddr, "`dest_addr` should have been overridden")
 
@@ -189,7 +189,7 @@ func (s *inputOverrideTestSuite) TestSetHostFromHostHeaderOverride() {
 		DestAddr: &originalDestAddr,
 	}
 
-	test.ApplyInputOverrides(&s.cfg.TestOverride.Overrides, &testInput)
+	test.ApplyInputOverrides(s.cfg, &testInput)
 
 	hostHeader := testInput.GetHeaders().Get("Host")
 	s.NotEqual("", hostHeader, "Host header must be set after overriding the `Host` header")
@@ -211,7 +211,7 @@ func (s *inputOverrideTestSuite) TestSetHeaderOverridingExistingOne() {
 
 	s.NotNil(testInput.Headers, "Header map must exist before overriding any header")
 
-	test.ApplyInputOverrides(&s.cfg.TestOverride.Overrides, &testInput)
+	test.ApplyInputOverrides(s.cfg, &testInput)
 
 	overriddenHeader := testInput.GetHeaders().Get("unique_id")
 	s.NotEqual("", overriddenHeader, "unique_id header must be set after overriding it")
@@ -229,7 +229,7 @@ func (s *inputOverrideTestSuite) TestApplyInputOverrides() {
 
 	s.NotNil(testInput.Headers, "Header map must exist before overriding any header")
 
-	test.ApplyInputOverrides(&s.cfg.TestOverride.Overrides, &testInput)
+	test.ApplyInputOverrides(s.cfg, &testInput)
 
 	overriddenHeader := testInput.GetHeaders().Get("unique_id")
 	s.NotEqual("", overriddenHeader, "unique_id header must be set after overriding it")
@@ -245,7 +245,7 @@ func (s *inputOverrideTestSuite) TestApplyInputOverrideURI() {
 		URI: &originalURI,
 	}
 
-	test.ApplyInputOverrides(&s.cfg.TestOverride.Overrides, &testInput)
+	test.ApplyInputOverrides(s.cfg, &testInput)
 
 	s.Equal(overrideURI, *testInput.URI, "`URI` should have been overridden")
 }
@@ -258,7 +258,7 @@ func (s *inputOverrideTestSuite) TestApplyInputOverrideVersion() {
 	testInput := test.Input{
 		Version: &originalVersion,
 	}
-	test.ApplyInputOverrides(&s.cfg.TestOverride.Overrides, &testInput)
+	test.ApplyInputOverrides(s.cfg, &testInput)
 
 	s.Equal(overrideVersion, *testInput.Version, "`Version` should have been overridden")
 }
@@ -271,7 +271,7 @@ func (s *inputOverrideTestSuite) TestApplyInputOverrideMethod() {
 	testInput := test.Input{
 		Method: &originalMethod,
 	}
-	test.ApplyInputOverrides(&s.cfg.TestOverride.Overrides, &testInput)
+	test.ApplyInputOverrides(s.cfg, &testInput)
 
 	s.Equal(overrideMethod, *testInput.Method, "`Method` should have been overridden")
 }
@@ -284,7 +284,7 @@ func (s *inputOverrideTestSuite) TestApplyInputOverrideData() {
 	testInput := test.Input{
 		Data: &originalData,
 	}
-	test.ApplyInputOverrides(&s.cfg.TestOverride.Overrides, &testInput)
+	test.ApplyInputOverrides(s.cfg, &testInput)
 
 	s.Equal(overrideData, *testInput.Data, "`Data` should have been overridden")
 }
@@ -297,7 +297,7 @@ func (s *inputOverrideTestSuite) TestApplyInputOverrideStopMagic() {
 	testInput := test.Input{
 		StopMagic: func() *bool { b := false; return &b }(),
 	}
-	test.ApplyInputOverrides(&s.cfg.TestOverride.Overrides, &testInput)
+	test.ApplyInputOverrides(s.cfg, &testInput)
 
 	// nolint
 	s.Equal(overrideStopMagic, *testInput.StopMagic, "`StopMagic` should have been overridden")
@@ -311,7 +311,7 @@ func (s *inputOverrideTestSuite) TestApplyInputOverrideAutocompleteHeaders() {
 	testInput := test.Input{
 		AutocompleteHeaders: func() *bool { b := false; return &b }(),
 	}
-	test.ApplyInputOverrides(&s.cfg.TestOverride.Overrides, &testInput)
+	test.ApplyInputOverrides(s.cfg, &testInput)
 
 	// nolint
 	s.Equal(overrideAutocompleteHeaders, *testInput.AutocompleteHeaders, "`AutocompleteHeaders` should have been overridden")
@@ -324,7 +324,7 @@ func (s *inputOverrideTestSuite) TestApplyInputOverrideNoAutocompleteHeaders() {
 	s.Nil(s.cfg.TestOverride.Overrides.AutocompleteHeaders)
 	//nolint:staticcheck
 	s.Nil(s.cfg.TestOverride.Overrides.StopMagic)
-	test.ApplyInputOverrides(&s.cfg.TestOverride.Overrides, &testInput)
+	test.ApplyInputOverrides(s.cfg, &testInput)
 
 	s.False(*testInput.AutocompleteHeaders, "`AutocompleteHeaders` should not have been overridden")
 }
@@ -336,8 +336,9 @@ func (s *inputOverrideTestSuite) TestApplyInputOverrideNoStopMagic() {
 	s.Nil(s.cfg.TestOverride.Overrides.AutocompleteHeaders)
 	//nolint:staticcheck
 	s.Nil(s.cfg.TestOverride.Overrides.StopMagic)
-	test.ApplyInputOverrides(&s.cfg.TestOverride.Overrides, &testInput)
+	test.ApplyInputOverrides(s.cfg, &testInput)
 
+	//nolint:staticcheck
 	s.True(*testInput.StopMagic, "`AutocompleteHeaders` should not have been overridden")
 }
 
@@ -348,7 +349,7 @@ func (s *inputOverrideTestSuite) TestApplyInputOverrideEncodedRequest() {
 	testInput := test.Input{
 		EncodedRequest: originalEncodedRequest,
 	}
-	test.ApplyInputOverrides(&s.cfg.TestOverride.Overrides, &testInput)
+	test.ApplyInputOverrides(s.cfg, &testInput)
 	s.NoError(err, "Failed to apply input overrides")
 	s.Equal(overrideEncodedRequest, testInput.EncodedRequest, "`EncodedRequest` should have been overridden")
 }
@@ -362,7 +363,8 @@ func (s *inputOverrideTestSuite) TestApplyInputOverrideRAWRequest() {
 		RAWRequest: originalRAWRequest,
 	}
 
-	test.ApplyInputOverrides(&s.cfg.TestOverride.Overrides, &testInput)
+	test.ApplyInputOverrides(s.cfg, &testInput)
 
+	//nolint:staticcheck
 	s.Equal(overrideRAWRequest, testInput.RAWRequest, "`RAWRequest` should have been overridden")
 }
diff --git a/runner/run_test.go b/runner/run_test.go
index 062b3ab..999738d 100644
--- a/runner/run_test.go
+++ b/runner/run_test.go
@@ -52,11 +52,11 @@ testoverride:
 	"TestIgnoredTestsRun": `---
 testoverride:
   ignore:
-    "001": "This test result must be ignored"
+    ".*-1": "This test result must be ignored"
   forcefail:
-    "008": "This test should pass, but it is going to fail"
+    ".*-8": "This test should pass, but it is going to fail"
   forcepass:
-    "099": "This test failed, but it shall pass!"
+    ".*-99": "This test failed, but it shall pass!"
 `,
 	"TestOverrideRun": `---
 testoverride:
@@ -108,22 +108,36 @@ type runTestSuite struct {
 	tempFileName string
 }
 
-// Error checking omitted for brevity
 func (s *runTestSuite) newTestServer(logLines string) {
+	s.newTestServerWithHandlerGenerator(nil, logLines)
+}
+
+// Error checking omitted for brevity
+func (s *runTestSuite) newTestServerWithHandlerGenerator(serverHandler func(logLines string) http.HandlerFunc, logLines string) {
 	var err error
+	var handler http.HandlerFunc
 	s.setUpLogFileForTestServer()
 
-	s.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		w.WriteHeader(http.StatusOK)
-		_, _ = w.Write([]byte("Hello, client"))
-
-		s.writeTestServerLog(logLines, r)
-	}))
+	if serverHandler == nil {
+		handler = s.getDefaultTestServerHandler(logLines)
+	} else {
+		handler = serverHandler(logLines)
+	}
+	s.ts = httptest.NewServer(handler)
 
 	s.dest, err = ftwhttp.DestinationFromString((s.ts).URL)
 	s.Require().NoError(err, "cannot get destination from string")
 }
 
+func (s *runTestSuite) getDefaultTestServerHandler(logLines string) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write([]byte("Hello, client"))
+
+		s.writeMarkerOrMessageToTestServerLog(logLines, r)
+	}
+}
+
 func (s *runTestSuite) setUpLogFileForTestServer() {
 	// log to the configured file
 	if s.cfg != nil && s.cfg.RunMode == config.DefaultRunMode {
@@ -137,7 +151,23 @@ func (s *runTestSuite) setUpLogFileForTestServer() {
 	}
 }
 
-func (s *runTestSuite) writeTestServerLog(logLines string, r *http.Request) {
+func (s *runTestSuite) writeTestServerLog(logLines string) {
+	file, err := os.OpenFile(s.logFilePath, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600)
+	s.Require().NoError(err, "cannot open file")
+
+	defer file.Close()
+
+	n, err := file.WriteString(logLines)
+	s.Len(logLines, n, "cannot write log message to file")
+	s.Require().NoError(err, "cannot write log message to file")
+
+	if logLines[len(logLines)-1] != '\n' {
+		_, err = file.WriteString("\n")
+		s.Require().NoError(err)
+	}
+}
+
+func (s *runTestSuite) writeMarkerOrMessageToTestServerLog(logLines string, r *http.Request) {
 	// write supplied log lines, emulating the output of the rule engine
 	logMessage := logLines
 	// if the request has the special test header, log the request instead
@@ -145,17 +175,14 @@ func (s *runTestSuite) writeTestServerLog(logLines string, r *http.Request) {
 	if r.Header.Get(s.cfg.LogMarkerHeaderName) != "" {
 		logMessage = fmt.Sprintf("request line: %s %s %s, headers: %s\n", r.Method, r.RequestURI, r.Proto, r.Header)
 	}
-	file, err := os.OpenFile(s.logFilePath, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600)
-	s.Require().NoError(err, "cannot open file")
-
-	defer file.Close()
+	s.writeTestServerLog(logMessage)
+}
 
-	n, err := file.WriteString(logMessage)
-	s.Len(logMessage, n, "cannot write log message to file")
-	s.Require().NoError(err, "cannot write log message to file")
+func (s *runTestSuite) TearDownTest() {
+	s.ts.Close()
 }
 
-func (s *runTestSuite) SetupTest() {
+func (s *runTestSuite) BeforeTest(_ string, name string) {
 	s.cfg = config.NewDefaultConfig()
 	// setup test webserver (not a waf)
 	s.newTestServer(logText)
@@ -163,18 +190,12 @@ func (s *runTestSuite) SetupTest() {
 		s.cfg.WithLogfile(s.logFilePath)
 	}
 
-	s.out = output.NewOutput("normal", os.Stdout)
-}
-
-func (s *runTestSuite) TearDownTest() {
-	s.ts.Close()
-}
-
-func (s *runTestSuite) BeforeTest(_ string, name string) {
 	var err error
 	var cfg string
 	var ok bool
 
+	s.out = output.NewOutput("normal", os.Stdout)
+
 	// if we have a configuration for this test, use it
 	// else use the default configuration
 	if cfg, ok = testConfigMap[name]; !ok {
@@ -215,7 +236,8 @@ func (s *runTestSuite) BeforeTest(_ string, name string) {
 	}
 
 	// create a temporary file to hold the test
-	testFileContents, err := os.CreateTemp("testdata", "mock-test-*.yaml")
+	tempDir := s.T().TempDir()
+	testFileContents, err := os.CreateTemp(tempDir, "mock-test-*.yaml")
 	s.Require().NoError(err, "cannot create temporary file")
 	err = tmpl.Execute(testFileContents, vars)
 	s.Require().NoError(err, "cannot execute template")
@@ -442,3 +464,69 @@ func (s *runTestSuite) TestGetRequestFromTestWithoutAutocompleteHeaders() {
 	s.Equal("", request.Headers().Get("Content-Length"), "Autocompletion is disabled")
 	s.Equal("", request.Headers().Get("Connection"), "Autocompletion is disabled")
 }
+
+// This test case verifies that the `retry_once` option works around a race condition in phase 5,
+// where the log entry for a phase 5 rule may appear after the end marker of the last test.
+// The race condition doesn't occur often, so retrying once should usually fix the issue.
+func (s *runTestSuite) TestRetryOnce() {
+	stageId := ""
+	serverHitCount := 0
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusOK)
+		_, _ = w.Write([]byte("Hello, client"))
+
+		// write logs on each start marker request
+		nextStageId := r.Header.Get(s.cfg.LogMarkerHeaderName)
+		if nextStageId != "" && nextStageId != stageId {
+			stageId = nextStageId
+			logMessage := ""
+			switch serverHitCount {
+			case 0:
+				// start marker
+				logMessage = fmt.Sprintf(
+					`[Sat Mar 18 16:47:21.474075 2023] [security2:error] [pid 193:tid 140523746522880] [client 172.18.0.1:39150] [client 172.18.0.1] ModSecurity: Warning. Pattern match "^.*$" at REQUEST_HEADERS:X-CRS-Test. [file "/etc/modsecurity.d/owasp-crs/crs-setup.conf"] [line "737"] [id "999999"] [msg "%s"] [tag "modsecurity"] [hostname "localhost"] [uri "/status/200"] [unique_id "ZBXrGVXqtKqlnATxdUEg7QAAANg"]`,
+					stageId)
+			case 1:
+				// hit without match + end marker
+				logMessage = fmt.Sprintf(
+					`[Sat Mar 18 16:47:21.476378 2023] [security2:error] [pid 193:tid 140524082149120] [client 172.18.0.1:39164] [client 172.18.0.1] ModSecurity: Warning. Pattern match "(?:^([\\\\d.]+|\\\\[[\\\\da-f:]+\\\\]|[\\\\da-f:]+)(:[\\\\d]+)?$)" at REQUEST_HEADERS:Host. [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-920-PROTOCOL-ENFORCEMENT.conf"] [line "761"] [id "920350"] [msg "Host header is a numeric IP address"] [data "127.0.0.1"] [severity "WARNING"] [ver "OWASP_CRS/4.0.0-rc1"] [tag "modsecurity"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-protocol"] [tag "paranoia-level/1"] [tag "OWASP_CRS"] [tag "capec/1000/210/272"] [tag "PCI/6.5.10"] [hostname "127.0.0.1"] [uri "/"] [unique_id "ZBXrGVXqtKqlnATxdUEg7gAAAMQ"]
+[Sat Mar 18 16:47:21.480771 2023] [security2:error] [pid 193:tid 140524098930432] [client 172.18.0.1:39172] [client 172.18.0.1] ModSecurity: Warning. Pattern match "^.*$" at REQUEST_HEADERS:X-CRS-Test. [file "/etc/modsecurity.d/owasp-crs/crs-setup.conf"] [line "737"] [id "999999"] [msg "%s"] [tag "modsecurity"] [hostname "localhost"] [uri "/status/200"] [unique_id "ZBXrGVXqtKqlnATxdUEg7wAAAMM"]`,
+					stageId)
+			case 2:
+				// late flushed phase 5 hit + start marker
+				logMessage = fmt.Sprintf(
+					`[Sat Mar 18 16:47:21.483333 2023] [security2:error] [pid 193:tid 140524082149120] [client 172.18.0.1:39164] [client 172.18.0.1] ModSecurity: Warning. Unconditional match in SecAction. [file "/etc/modsecurity.d/owasp-crs/rules/RESPONSE-980-CORRELATION.conf"] [line "96"] [id "980170"] [msg "Anomaly Scores: (Inbound Scores: blocking=3, detection=3, per_pl=3-0-0-0, threshold=5) - (Outbound Scores: blocking=0, detection=0, per_pl=0-0-0-0, threshold=4) - (SQLI=0, XSS=0, RFI=0, LFI=0, RCE=0, PHPI=0, HTTP=0, SESS=0, COMBINED_SCORE=3)"] [ver "OWASP_CRS/4.0.0-rc1"] [tag "modsecurity"] [tag "reporting"] [hostname "127.0.0.1"] [uri "/"] [unique_id "ZBXrGVXqtKqlnATxdUEg7gAAAMQ"]
+[Sat Mar 18 16:47:21.474075 2023] [security2:error] [pid 193:tid 140523746522880] [client 172.18.0.1:39150] [client 172.18.0.1] ModSecurity: Warning. Pattern match "^.*$" at REQUEST_HEADERS:X-CRS-Test. [file "/etc/modsecurity.d/owasp-crs/crs-setup.conf"] [line "737"] [id "999999"] [msg "%s"] [tag "modsecurity"] [hostname "localhost"] [uri "/status/200"] [unique_id "ZBXrGVXqtKqlnATxdUEg7QAAANg"]`,
+					stageId)
+			default:
+				// hit with match + end marker
+				logMessage = fmt.Sprintf(
+					`[Sat Mar 18 16:47:21.476378 2023] [security2:error] [pid 193:tid 140524082149120] [client 172.18.0.1:39164] [client 172.18.0.1] ModSecurity: Warning. Pattern match "(?:^([\\\\d.]+|\\\\[[\\\\da-f:]+\\\\]|[\\\\da-f:]+)(:[\\\\d]+)?$)" at REQUEST_HEADERS:Host. [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-920-PROTOCOL-ENFORCEMENT.conf"] [line "761"] [id "920350"] [msg "Host header is a numeric IP address"] [data "127.0.0.1"] [severity "WARNING"] [ver "OWASP_CRS/4.0.0-rc1"] [tag "modsecurity"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-protocol"] [tag "paranoia-level/1"] [tag "OWASP_CRS"] [tag "capec/1000/210/272"] [tag "PCI/6.5.10"] [hostname "127.0.0.1"] [uri "/"] [unique_id "ZBXrGVXqtKqlnATxdUEg7gAAAMQ"]
+[Sat Mar 18 16:47:21.483333 2023] [security2:error] [pid 193:tid 140524082149120] [client 172.18.0.1:39164] [client 172.18.0.1] ModSecurity: Warning. Unconditional match in SecAction. [file "/etc/modsecurity.d/owasp-crs/rules/RESPONSE-980-CORRELATION.conf"] [line "96"] [id "980170"] [msg "Anomaly Scores: (Inbound Scores: blocking=3, detection=3, per_pl=3-0-0-0, threshold=5) - (Outbound Scores: blocking=0, detection=0, per_pl=0-0-0-0, threshold=4) - (SQLI=0, XSS=0, RFI=0, LFI=0, RCE=0, PHPI=0, HTTP=0, SESS=0, COMBINED_SCORE=3)"] [ver "OWASP_CRS/4.0.0-rc1"] [tag "modsecurity"] [tag "reporting"] [hostname "127.0.0.1"] [uri "/"] [unique_id "ZBXrGVXqtKqlnATxdUEg7gAAAMQ"]
+[Sat Mar 18 16:47:21.480771 2023] [security2:error] [pid 193:tid 140524098930432] [client 172.18.0.1:39172] [client 172.18.0.1] ModSecurity: Warning. Pattern match "^.*$" at REQUEST_HEADERS:X-CRS-Test. [file "/etc/modsecurity.d/owasp-crs/crs-setup.conf"] [line "737"] [id "999999"] [msg "%s"] [tag "modsecurity"] [hostname "localhost"] [uri "/status/200"] [unique_id "ZBXrGVXqtKqlnATxdUEg7wAAAMM"]`,
+					stageId)
+			}
+
+			s.writeTestServerLog(logMessage)
+		}
+
+		serverHitCount++
+	}
+
+	s.ts.Config.Handler = http.HandlerFunc(handler)
+	res, err := Run(s.cfg, s.ftwTests, RunnerConfig{
+		Output: output.Quiet,
+	}, s.out)
+	s.Require().NoError(err)
+	s.Equalf(res.Stats.TotalFailed(), 0, "Oops, %d tests failed to run!", res.Stats.TotalFailed())
+}
+
+func (s *runTestSuite) TestFailFast() {
+	s.Equal(3, len(s.ftwTests[0].Tests))
+
+	res, err := Run(s.cfg, s.ftwTests, RunnerConfig{FailFast: true}, s.out)
+	s.Require().NoError(err)
+	s.Equal(1, res.Stats.TotalFailed(), "Oops, test run failed!")
+	s.Equal(2, res.Stats.Run)
+}
diff --git a/runner/testdata/TestBrokenOverrideRun.yaml b/runner/testdata/TestBrokenOverrideRun.yaml
index ab6b84b..e7da3d7 100644
--- a/runner/testdata/TestBrokenOverrideRun.yaml
+++ b/runner/testdata/TestBrokenOverrideRun.yaml
@@ -5,15 +5,14 @@ meta:
   name: "TestBrokenOverrideRun.yaml"
   description: "Example Override Test"
 tests:
-  - test_title: "001"
+  - test_id: 1
     description: "access real external site"
     stages:
-      - stage:
-          input:
-            dest_addr: "{{ .TestAddr }}"
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Host: "{{ .TestAddr }}"
-          output:
-            expect_error: False
-            status: [200]
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Host: "{{ .TestAddr }}"
+        output:
+          expect_error: False
+          status: 200
diff --git a/runner/testdata/TestBrokenPortOverrideRun.yaml b/runner/testdata/TestBrokenPortOverrideRun.yaml
index b5dd26a..484a8f1 100644
--- a/runner/testdata/TestBrokenPortOverrideRun.yaml
+++ b/runner/testdata/TestBrokenPortOverrideRun.yaml
@@ -5,15 +5,14 @@ meta:
   name: "TestBrokenPortOverrideRun.yaml"
   description: "Example Override Test"
 tests:
-  - test_title: "001"
+  - test_id: 1
     description: "access real external site"
     stages:
-      - stage:
-          input:
-            dest_addr: "{{ .TestAddr }}"
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Host: "{{ .TestAddr }}"
-          output:
-            expect_error: False
-            status: [200]
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Host: "{{ .TestAddr }}"
+        output:
+          expect_error: False
+          status: 200
diff --git a/runner/testdata/TestCloudRun.yaml b/runner/testdata/TestCloudRun.yaml
index 7d7dedc..1a6c3fe 100644
--- a/runner/testdata/TestCloudRun.yaml
+++ b/runner/testdata/TestCloudRun.yaml
@@ -5,102 +5,95 @@ meta:
   name: "TestCloudRun.yaml"
   description: "Example Test"
 tests:
-  - test_title: "001"
+  - test_id: 1
     description: "access real external site"
     stages:
-      - stage:
-          input:
-            uri: "/200"
-            dest_addr: "{{ .TestAddr }}"
-            port: {{ .TestPort }}
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "{{ .TestAddr }}"
-          output:
-            expect_error: False
-            status: [200]
-  - test_title: "403"
+      - input:
+          uri: "/200"
+          dest_addr: "{{ .TestAddr }}"
+          port: {{ .TestPort }}
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "{{ .TestAddr }}"
+        output:
+          expect_error: False
+          status: 200
+  - test_id: 403
     description: "using log_contains should return a 403 response."
     stages:
-      - stage:
-          input:
-            uri: "/403"
-            dest_addr: "{{ .TestAddr }}"
-            port: {{ .TestPort }}
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "{{ .TestAddr }}"
-          output:
-            log_contains: "ModSecurity: Access denied with code 403"
-  - test_title: "405"
+      - input:
+          uri: "/403"
+          dest_addr: "{{ .TestAddr }}"
+          port: {{ .TestPort }}
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "{{ .TestAddr }}"
+        output:
+          log_contains: "ModSecurity: Access denied with code 403"
+  - test_id: 405
     description: "using no_log_contains should return a 405 response."
     stages:
-      - stage:
-          input:
-            uri: "/405"
-            dest_addr: "{{ .TestAddr }}"
-            port: {{ .TestPort }}
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "{{ .TestAddr }}"
-          output:
-            no_log_contains: "ModSecurity: Access denied with code 403"
-  - test_title: "008"
+      - input:
+          uri: "/405"
+          dest_addr: "{{ .TestAddr }}"
+          port: {{ .TestPort }}
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "{{ .TestAddr }}"
+        output:
+          no_log_contains: "ModSecurity: Access denied with code 403"
+  - test_id: 8
     description: "this test is number 8"
     stages:
-      - stage:
-          input:
-            uri: "/200"
-            dest_addr: "{{ .TestAddr }}"
-            port: {{ .TestPort }}
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "localhost"
-          output:
-            status: [200]
-  - test_title: "010"
+      - input:
+          uri: "/200"
+          dest_addr: "{{ .TestAddr }}"
+          port: {{ .TestPort }}
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "localhost"
+        output:
+          status: 200
+  - test_id: 10
     stages:
-      - stage:
-          input:
-            uri: "/200"
-            dest_addr: "{{ .TestAddr }}"
-            port: {{ .TestPort }}
-            version: "HTTP/1.1"
-            method: "OTHER"
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "localhost"
-          output:
-            response_contains: "Hello, client"
-  - test_title: "101"
+      - input:
+          uri: "/200"
+          dest_addr: "{{ .TestAddr }}"
+          port: {{ .TestPort }}
+          version: "HTTP/1.1"
+          method: "OTHER"
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "localhost"
+        output:
+          response_contains: "Hello, client"
+  - test_id: 101
     description: "this tests exceptions (connection timeout)"
     stages:
-      - stage:
-          input:
-            dest_addr: "{{ .TestAddr }}"
-            port: 8090
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "none.host"
-          output:
-            expect_error: True
-  - test_title: "102"
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: 8090
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "none.host"
+        output:
+          expect_error: True
+  - test_id: 102
     description: "this tests exceptions (connection timeout)"
     stages:
-      - stage:
-          input:
-            dest_addr: "{{ .TestAddr }}"
-            port: 8090
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Host: "none.host"
-              Accept: "*/*"
-            encoded_request: "UE9TVCAvaW5kZXguaHRtbCBIVFRQLzEuMQ0KSG9zdDogMTkyLjE2OC4xLjIzDQpVc2VyLUFnZW50OiBjdXJsLzcuNDMuMA0KQWNjZXB0OiAqLyoNCkNvbnRlbnQtTGVuZ3RoOiA2NA0KQ29udGVudC1UeXBlOiBhcHBsaWNhdGlvbi94LXd3dy1mb3JtLXVybGVuY29kZWQNCkNvbm5lY3Rpb246IGNsb3NlDQoNCmQ9MTsyOzM7NDs1XG4xO0BTVU0oMSsxKSpjbWR8JyBwb3dlcnNoZWxsIElFWCh3Z2V0IDByLnBlL3ApJ1whQTA7Mw=="
-          output:
-            expect_error: True
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: 8090
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Host: "none.host"
+            Accept: "*/*"
+          encoded_request: "UE9TVCAvaW5kZXguaHRtbCBIVFRQLzEuMQ0KSG9zdDogMTkyLjE2OC4xLjIzDQpVc2VyLUFnZW50OiBjdXJsLzcuNDMuMA0KQWNjZXB0OiAqLyoNCkNvbnRlbnQtTGVuZ3RoOiA2NA0KQ29udGVudC1UeXBlOiBhcHBsaWNhdGlvbi94LXd3dy1mb3JtLXVybGVuY29kZWQNCkNvbm5lY3Rpb246IGNsb3NlDQoNCmQ9MTsyOzM7NDs1XG4xO0BTVU0oMSsxKSpjbWR8JyBwb3dlcnNoZWxsIElFWCh3Z2V0IDByLnBlL3ApJ1whQTA7Mw=="
+        output:
+          expect_error: True
diff --git a/runner/testdata/TestDisabledRun.yaml b/runner/testdata/TestDisabledRun.yaml
index 0433bb9..bf1a03d 100644
--- a/runner/testdata/TestDisabledRun.yaml
+++ b/runner/testdata/TestDisabledRun.yaml
@@ -5,15 +5,14 @@ meta:
   name: "TestDisabledRun.yaml"
   description: "we do not care, this test is disabled"
 tests:
-  - test_title: "001"
+  - test_id: 1
     description: "access real external site"
     stages:
-      - stage:
-          input:
-            dest_addr: "{{ .TestAddr }}"
-            port:  {{ .TestPort }}
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Host: "{{ .TestAddr }}"
-          output:
-            status: [1234]
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port:  {{ .TestPort }}
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Host: "{{ .TestAddr }}"
+        output:
+          status: 1234
diff --git a/runner/testdata/TestFailFast.yaml b/runner/testdata/TestFailFast.yaml
new file mode 100644
index 0000000..e6594a8
--- /dev/null
+++ b/runner/testdata/TestFailFast.yaml
@@ -0,0 +1,45 @@
+---
+meta:
+  author: "tester"
+  name: "TestFailFast.yaml"
+  description: "Example Test"
+tests:
+  - test_id: 1
+    description: "succeed"
+    stages:
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: {{ .TestPort }}
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "{{ .TestAddr }}"
+        output:
+          status: 200
+  - test_id: 2
+    description: "fail"
+    stages:
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: {{ .TestPort }}
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "localhost"
+        output:
+          log:
+            match_regex: '--nothing--'
+  - test_id: 3
+    description: "not executed with fail-fast = true"
+    stages:
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: {{ .TestPort }}
+          version: "HTTP/1.1"
+          method: "OTHER"
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "localhost"
+        output:
+          response_contains: "Hello, client"
diff --git a/runner/testdata/TestFailedTestsRun.yaml b/runner/testdata/TestFailedTestsRun.yaml
index 54140df..d1f44f4 100644
--- a/runner/testdata/TestFailedTestsRun.yaml
+++ b/runner/testdata/TestFailedTestsRun.yaml
@@ -5,17 +5,15 @@ meta:
   name: "TestFailedTestsRun.yaml"
   description: "Example Test"
 tests:
-  - test_title: "990"
+  - test_id: 990
     description: test that fails
     stages:
-      - stage:
-          input:
-            dest_addr: "{{ .TestAddr }}"
-            # -1 designates port value must be replaced by test setup
-            port: {{ .TestPort }}
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "none.host"
-          output:
-            status: [413]
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: {{ .TestPort }}
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "none.host"
+        output:
+          status: 413
diff --git a/runner/testdata/TestIgnoredTestsRun.yaml b/runner/testdata/TestIgnoredTestsRun.yaml
index 8b5f5f9..f824827 100644
--- a/runner/testdata/TestIgnoredTestsRun.yaml
+++ b/runner/testdata/TestIgnoredTestsRun.yaml
@@ -5,71 +5,66 @@ meta:
   name: "TestIgnoredTestsRun.yaml"
   description: "Example Test"
 tests:
-  - test_title: "001"
+  - test_id: 1
     description: "access real external site"
     stages:
-      - stage:
-          input:
-            dest_addr: "{{ .TestAddr }}"
-            port: {{ .TestPort }}
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "{{ .TestAddr }}"
-          output:
-            expect_error: False
-            status: [200]
-  - test_title: "008"
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: {{ .TestPort }}
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "{{ .TestAddr }}"
+        output:
+          expect_error: False
+          status: 200
+  - test_id: 8
     description: "this test is number 8"
     stages:
-      - stage:
-          input:
-            dest_addr: "{{ .TestAddr }}"
-            port: {{ .TestPort }}
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "localhost"
-          output:
-            status: [200]
-  - test_title: "010"
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: {{ .TestPort }}
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "localhost"
+        output:
+          status: 200
+  - test_id: 10
     stages:
-      - stage:
-          input:
-            dest_addr: "{{ .TestAddr }}"
-            port: {{ .TestPort }}
-            version: "HTTP/1.1"
-            method: "OTHER"
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "localhost"
-          output:
-            response_contains: "Hello, client"
-  - test_title: "101"
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: {{ .TestPort }}
+          version: "HTTP/1.1"
+          method: "OTHER"
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "localhost"
+        output:
+          response_contains: "Hello, client"
+  - test_id: 101
     description: "this tests exceptions (connection timeout)"
     stages:
-      - stage:
-          input:
-            dest_addr: "{{ .TestAddr }}"
-            port: 8090
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "none.host"
-          output:
-            expect_error: True
-  - test_title: "102"
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: 8090
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "none.host"
+        output:
+          expect_error: True
+  - test_id: 102
     description: "this tests exceptions (connection timeout)"
     stages:
-      - stage:
-          input:
-            dest_addr: "{{ .TestAddr }}"
-            port: 8090
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Host: "none.host"
-              Accept: "*/*"
-            encoded_request: "UE9TVCAvaW5kZXguaHRtbCBIVFRQLzEuMQ0KSG9zdDogMTkyLjE2OC4xLjIzDQpVc2VyLUFnZW50OiBjdXJsLzcuNDMuMA0KQWNjZXB0OiAqLyoNCkNvbnRlbnQtTGVuZ3RoOiA2NA0KQ29udGVudC1UeXBlOiBhcHBsaWNhdGlvbi94LXd3dy1mb3JtLXVybGVuY29kZWQNCkNvbm5lY3Rpb246IGNsb3NlDQoNCmQ9MTsyOzM7NDs1XG4xO0BTVU0oMSsxKSpjbWR8JyBwb3dlcnNoZWxsIElFWCh3Z2V0IDByLnBlL3ApJ1whQTA7Mw=="
-          output:
-            expect_error: True
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: 8090
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Host: "none.host"
+            Accept: "*/*"
+          encoded_request: "UE9TVCAvaW5kZXguaHRtbCBIVFRQLzEuMQ0KSG9zdDogMTkyLjE2OC4xLjIzDQpVc2VyLUFnZW50OiBjdXJsLzcuNDMuMA0KQWNjZXB0OiAqLyoNCkNvbnRlbnQtTGVuZ3RoOiA2NA0KQ29udGVudC1UeXBlOiBhcHBsaWNhdGlvbi94LXd3dy1mb3JtLXVybGVuY29kZWQNCkNvbm5lY3Rpb246IGNsb3NlDQoNCmQ9MTsyOzM7NDs1XG4xO0BTVU0oMSsxKSpjbWR8JyBwb3dlcnNoZWxsIElFWCh3Z2V0IDByLnBlL3ApJ1whQTA7Mw=="
+        output:
+          expect_error: True
diff --git a/runner/testdata/TestLogsRun.yaml b/runner/testdata/TestLogsRun.yaml
index 5c62b86..94e077d 100644
--- a/runner/testdata/TestLogsRun.yaml
+++ b/runner/testdata/TestLogsRun.yaml
@@ -5,29 +5,25 @@ meta:
   name: "TestLogsRun.yaml"
   description: "Example Test"
 tests:
-  - test_title: "200"
+  - test_id: 200
     stages:
-      - stage:
-          input:
-            dest_addr: "{{ .TestAddr }}"
-            # -1 designates port value must be replaced by test setup
-            port: {{ .TestPort }}
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "localhost"
-          output:
-            log_contains: id \"949110\"
-  - test_title: "201"
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: {{ .TestPort }}
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "localhost"
+        output:
+          log_contains: id \"949110\"
+  - test_id: 201
     stages:
-      - stage:
-          input:
-            dest_addr: "{{ .TestAddr }}"
-            # -1 designates port value must be replaced by test setup
-            port: {{ .TestPort }}
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "localhost"
-          output:
-            no_log_contains: ABCDE
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: {{ .TestPort }}
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "localhost"
+        output:
+          no_log_contains: ABCDE
diff --git a/runner/testdata/TestOverrideRun.yaml b/runner/testdata/TestOverrideRun.yaml
index 08c448e..88ecf01 100644
--- a/runner/testdata/TestOverrideRun.yaml
+++ b/runner/testdata/TestOverrideRun.yaml
@@ -5,17 +5,15 @@ meta:
   name: "TestOverrideRun.yaml"
   description: "Example Override Test"
 tests:
-  - test_title: "001"
+  - test_id: 1
     description: "access real external site"
     stages:
-      - stage:
-          input:
-            dest_addr: "{{ .TestAddr }}"
-            # -1 designates port value must be replaced by test setup
-            port: {{ .TestPort }}
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Host: "{{ .TestAddr }}
-          output:
-            expect_error: False
-            status: [200]
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: {{ .TestPort }}
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Host: "{{ .TestAddr }}"
+        output:
+          expect_error: False
+          status: 200
diff --git a/runner/testdata/TestRetryOnce.yaml b/runner/testdata/TestRetryOnce.yaml
new file mode 100644
index 0000000..0c69d53
--- /dev/null
+++ b/runner/testdata/TestRetryOnce.yaml
@@ -0,0 +1,21 @@
+---
+meta:
+  author: "tester"
+  enabled: true
+  name: "TestRetryOnce.yaml"
+  description: "Example Test"
+tests:
+  - test_id: 1
+    description: "used to simulate race condition in 980170"
+    stages:
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: {{ .TestPort }}
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "{{ .TestAddr }}"
+        output:
+          expect_error: false
+          retry_once: true
+          log_contains: id "980170"
diff --git a/runner/testdata/TestRunMultipleMatches.yaml b/runner/testdata/TestRunMultipleMatches.yaml
index a9c64ab..710750f 100644
--- a/runner/testdata/TestRunMultipleMatches.yaml
+++ b/runner/testdata/TestRunMultipleMatches.yaml
@@ -1,22 +1,20 @@
 ---
 meta:
-    author: "tester"
-    enabled: true
-    name: "TestRunMultipleMatches.yaml"
-    description: "Example Test with multiple expected outputs per single rule"
+  author: "tester"
+  enabled: true
+  name: "TestRunMultipleMatches.yaml"
+  description: "Example Test with multiple expected outputs per single rule"
 tests:
-    - test_title: "001"
-      description: "access real external site"
-      stages:
-          - stage:
-                input:
-                    dest_addr: "{{ .TestAddr }}"
-                    # -1 designates port value must be replaced by test setup
-                    port: {{ .TestPort }}
-                    headers:
-                        User-Agent: "ModSecurity CRS 3 Tests"
-                        Accept: "*/*"
-                        Host: "{{ .TestAddr }}"
-                output:
-                    status: [200]
-                    response_contains: "Not contains this"
+  - test_id: 1
+    description: "access real external site"
+    stages:
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: {{ .TestPort }}
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "{{ .TestAddr }}"
+        output:
+          status: 200
+          response_contains: "Not contains this"
diff --git a/runner/testdata/TestRunTests_Run.yaml b/runner/testdata/TestRunTests_Run.yaml
index 82b506f..8582848 100644
--- a/runner/testdata/TestRunTests_Run.yaml
+++ b/runner/testdata/TestRunTests_Run.yaml
@@ -5,71 +5,66 @@ meta:
   name: "TestRunTests_Run.yaml"
   description: "Example Test"
 tests:
-  - test_title: "001"
+  - test_id: 1
     description: "access real external site"
     stages:
-      - stage:
-          input:
-            dest_addr: "{{ .TestAddr }}"
-            port: {{ .TestPort }}
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "{{ .TestAddr }}"
-          output:
-            expect_error: False
-            status: [200]
-  - test_title: "008"
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: {{ .TestPort }}
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "{{ .TestAddr }}"
+        output:
+          expect_error: False
+          status: 200
+  - test_id: 8
     description: "this test is number 8"
     stages:
-      - stage:
-          input:
-            dest_addr: "{{ .TestAddr }}"
-            port: {{ .TestPort }}
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "localhost"
-          output:
-            status: [200]
-  - test_title: "010"
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: {{ .TestPort }}
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "localhost"
+        output:
+          status: 200
+  - test_id: 10
     stages:
-      - stage:
-          input:
-            dest_addr: "{{ .TestAddr }}"
-            port: {{ .TestPort }}
-            version: "HTTP/1.1"
-            method: "OTHER"
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "localhost"
-          output:
-            response_contains: "Hello, client"
-  - test_title: "101"
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: {{ .TestPort }}
+          version: "HTTP/1.1"
+          method: "OTHER"
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "localhost"
+        output:
+          response_contains: "Hello, client"
+  - test_id: 101
     description: "this tests exceptions (connection timeout)"
     stages:
-      - stage:
-          input:
-            dest_addr: "{{ .TestAddr }}"
-            port: 8090
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "none.host"
-          output:
-            expect_error: True
-  - test_title: "102"
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: 8090
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "none.host"
+        output:
+          expect_error: True
+  - test_id: 102
     description: "this tests exceptions (connection timeout)"
     stages:
-      - stage:
-          input:
-            dest_addr: "{{ .TestAddr }}"
-            port: 8090
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Host: "none.host"
-              Accept: "*/*"
-            encoded_request: "UE9TVCAvaW5kZXguaHRtbCBIVFRQLzEuMQ0KSG9zdDogMTkyLjE2OC4xLjIzDQpVc2VyLUFnZW50OiBjdXJsLzcuNDMuMA0KQWNjZXB0OiAqLyoNCkNvbnRlbnQtTGVuZ3RoOiA2NA0KQ29udGVudC1UeXBlOiBhcHBsaWNhdGlvbi94LXd3dy1mb3JtLXVybGVuY29kZWQNCkNvbm5lY3Rpb246IGNsb3NlDQoNCmQ9MTsyOzM7NDs1XG4xO0BTVU0oMSsxKSpjbWR8JyBwb3dlcnNoZWxsIElFWCh3Z2V0IDByLnBlL3ApJ1whQTA7Mw=="
-          output:
-            expect_error: True
+      - input:
+          dest_addr: "{{ .TestAddr }}"
+          port: 8090
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Host: "none.host"
+            Accept: "*/*"
+          encoded_request: "UE9TVCAvaW5kZXguaHRtbCBIVFRQLzEuMQ0KSG9zdDogMTkyLjE2OC4xLjIzDQpVc2VyLUFnZW50OiBjdXJsLzcuNDMuMA0KQWNjZXB0OiAqLyoNCkNvbnRlbnQtTGVuZ3RoOiA2NA0KQ29udGVudC1UeXBlOiBhcHBsaWNhdGlvbi94LXd3dy1mb3JtLXVybGVuY29kZWQNCkNvbm5lY3Rpb246IGNsb3NlDQoNCmQ9MTsyOzM7NDs1XG4xO0BTVU0oMSsxKSpjbWR8JyBwb3dlcnNoZWxsIElFWCh3Z2V0IDByLnBlL3ApJ1whQTA7Mw=="
+        output:
+          expect_error: True
diff --git a/test/defaults.go b/test/defaults.go
index fb6a50c..87ecf6c 100644
--- a/test/defaults.go
+++ b/test/defaults.go
@@ -13,7 +13,10 @@ import (
 
 type Input schema.Input
 type Output schema.Output
-type FTWTest schema.FTWTest
+type FTWTest struct {
+	schema.FTWTest `yaml:",inline"`
+	FileName       string
+}
 
 // GetMethod returns the proper semantic when the field is empty
 func (i *Input) GetMethod() string {
@@ -77,7 +80,9 @@ func (i *Input) GetRawRequest() ([]byte, error) {
 		// if Encoded, first base64 decode, then dump
 		return base64.StdEncoding.DecodeString(i.EncodedRequest)
 	}
+	//nolint:staticcheck
 	if utils.IsNotEmpty(i.RAWRequest) {
+		//nolint:staticcheck
 		return []byte(i.RAWRequest), nil
 	}
 	return nil, nil
diff --git a/test/errors.go b/test/errors.go
deleted file mode 100644
index 6d4eccd..0000000
--- a/test/errors.go
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright 2023 OWASP ModSecurity Core Rule Set Project
-// SPDX-License-Identifier: Apache-2.0
-
-package test
-
-import (
-	"bufio"
-	"fmt"
-	"os"
-	"regexp"
-
-	"github.com/rs/zerolog/log"
-)
-
-// GetLinesFromTest get the output lines from a test name, to show in errors
-func (f *FTWTest) GetLinesFromTest(testName string) (int, error) {
-	file, err := os.Open(f.FileName)
-	if err != nil {
-		log.Info().Msgf("yamlFile.Get err   #%v ", err)
-	}
-	match := fmt.Sprintf("test_title: %s", testName)
-	scanner := bufio.NewScanner(file)
-	scanner.Split(bufio.ScanLines)
-	line := 1
-
-	for scanner.Scan() {
-		log.Debug().Msgf("%d - %s\n", line, scanner.Text())
-		got, err := regexp.Match(match, []byte(scanner.Text()))
-		if err != nil {
-			log.Fatal().Msgf("ftw/test/error: bad regexp %s", err.Error())
-		}
-		if got {
-			log.Trace().Msgf("ftw/test/error: Found %s at %d", match, line)
-			break
-		}
-		line++
-	}
-
-	return line, err
-}
diff --git a/test/errors_test.go b/test/errors_test.go
deleted file mode 100644
index 6b485be..0000000
--- a/test/errors_test.go
+++ /dev/null
@@ -1,66 +0,0 @@
-// Copyright 2023 OWASP ModSecurity Core Rule Set Project
-// SPDX-License-Identifier: Apache-2.0
-
-package test
-
-import (
-	"testing"
-
-	"github.com/stretchr/testify/suite"
-
-	"github.com/coreruleset/go-ftw/utils"
-)
-
-var errorsTest = `---
-  meta:
-    author: "tester"
-    enabled: true
-    name: "911100.yaml"
-    description: "Description"
-  tests:
-    -
-      test_title: 911100-1
-      stages:
-        -
-          stage:
-            input:
-              dest_addr: "127.0.0.1"
-              port: 80
-              headers:
-                  User-Agent: "ModSecurity CRS 3 Tests"
-                  Host: "localhost"
-            output:
-              no_log_contains: "id \"911100\""
-    -
-      test_title: 911100-2
-      stages:
-        -
-          stage:
-            input:
-              dest_addr: "127.0.0.1"
-              port: 80
-              method: "OPTIONS"
-              headers:
-                  User-Agent: "ModSecurity CRS 3 Tests"
-                  Host: "localhost"
-            output:
-              no_log_contains: "id \"911100\""
-`
-
-type errorsTestSuite struct {
-	suite.Suite
-}
-
-func TestErrorsTestSuite(t *testing.T) {
-	suite.Run(t, new(errorsTestSuite))
-}
-
-func (s *errorsTestSuite) TestGetLinesFromTestName() {
-	filename, _ := utils.CreateTempFileWithContent(errorsTest, "test-yaml-*")
-	tests, _ := GetTestsFromFiles(filename)
-
-	for _, ft := range tests {
-		line, _ := ft.GetLinesFromTest("911100-2")
-		s.Equal(22, line, "Not getting the proper line.")
-	}
-}
diff --git a/test/files.go b/test/files.go
index 5bf4ffd..26c78d0 100644
--- a/test/files.go
+++ b/test/files.go
@@ -6,6 +6,7 @@ package test
 import (
 	"errors"
 	"os"
+	"path"
 
 	"github.com/goccy/go-yaml"
 	"github.com/rs/zerolog/log"
@@ -28,20 +29,19 @@ func GetTestsFromFiles(globPattern string) ([]*FTWTest, error) {
 		return tests, err
 	}
 
-	for _, fileName := range testFiles {
-		yamlString, err := readFileContents(fileName)
+	for _, filePath := range testFiles {
+		yamlString, err := readFileContents(filePath)
 		if err != nil {
 			return tests, err
 		}
-		ftwTest, err := GetTestFromYaml(yamlString)
+		ftwTest, err := GetTestFromYaml(yamlString, path.Base(filePath))
 		if err != nil {
 			log.Error().Msgf("Problem detected in file %s:\n%s\n%s",
-				fileName, yaml.FormatError(err, true, true),
+				filePath, yaml.FormatError(err, true, true),
 				DescribeYamlError(err))
 			return tests, err
 		}
 
-		ftwTest.FileName = fileName
 		tests = append(tests, ftwTest)
 	}
 
diff --git a/test/files_test.go b/test/files_test.go
index e24bf1e..e009756 100644
--- a/test/files_test.go
+++ b/test/files_test.go
@@ -19,35 +19,31 @@ var yamlTest = `
     enabled: true
     name: "911100.yaml"
     description: "Description"
+  rule_id: 911100
   tests:
-    -
-      test_title: 911100-1
+    - test_id: 1
       stages:
-        -
-          stage:
-            input:
-              autocomplete_headers: false
-              dest_addr: "127.0.0.1"
-              port: 80
-              headers:
-                  User-Agent: "ModSecurity CRS 3 Tests"
-                  Host: "localhost"
-            output:
-              no_log_contains: "id \"911100\""
+        - input:
+            autocomplete_headers: false
+            dest_addr: "127.0.0.1"
+            port: 80
+            headers:
+              User-Agent: "ModSecurity CRS 3 Tests"
+              Host: "localhost"
+          output:
+            no_log_contains: "id \"911100\""
     -
-      test_title: 911100-2
+      test_id: 2
       stages:
-        -
-          stage:
-            input:
-              dest_addr: "127.0.0.1"
-              port: 80
-              method: "OPTIONS"
-              headers:
-                  User-Agent: "ModSecurity CRS 3 Tests"
-                  Host: "localhost"
-            output:
-              no_log_contains: "id \"911100\""
+        - input:
+            dest_addr: "127.0.0.1"
+            port: 80
+            method: "OPTIONS"
+            headers:
+              User-Agent: "ModSecurity CRS 3 Tests"
+              Host: "localhost"
+          output:
+            no_log_contains: "id \"911100\""
 `
 
 var wrongYamlTest = `
@@ -67,14 +63,13 @@ func (s *filesTestSuite) TestGetTestFromYAML() {
 	tests, _ := GetTestsFromFiles(filename)
 
 	for _, ft := range tests {
-		s.Equal(filename, ft.FileName)
 		s.Equal("tester", ft.Meta.Author)
 		s.Equal("911100.yaml", ft.Meta.Name)
 
-		re := regexp.MustCompile("911100*")
+		re := regexp.MustCompile("911100.*")
 
 		for _, test := range ft.Tests {
-			s.True(re.MatchString(test.TestTitle), "Can't read test title")
+			s.True(re.MatchString(test.IdString()), "Can't read test identifier")
 		}
 	}
 }
diff --git a/test/testdata/TestCheckBenchmarkCheckFiles.yaml b/test/testdata/TestCheckBenchmarkCheckFiles.yaml
index f4278e1..18636ed 100644
--- a/test/testdata/TestCheckBenchmarkCheckFiles.yaml
+++ b/test/testdata/TestCheckBenchmarkCheckFiles.yaml
@@ -4,6 +4,7 @@ meta:
   enabled: true
   name: "920420.yaml"
   description: "Description"
+rule_id: 920420
 tests:
   - test_title: 920420-1
     stages:
diff --git a/test/types.go b/test/types.go
index 112c6eb..6a1e1fe 100644
--- a/test/types.go
+++ b/test/types.go
@@ -4,40 +4,62 @@
 package test
 
 import (
-	"github.com/coreruleset/ftw-tests-schema/types"
+	"regexp"
+	"slices"
+	"strconv"
+
+	schema "github.com/coreruleset/ftw-tests-schema/types"
+	overridesSchema "github.com/coreruleset/ftw-tests-schema/types/overrides"
+	"github.com/coreruleset/go-ftw/config"
 	"github.com/coreruleset/go-ftw/ftwhttp"
+	"github.com/rs/zerolog/log"
 )
 
-// Overrides represents the overridden inputs that have to be applied to tests
-type Overrides struct {
-	DestAddr   *string        `yaml:"dest_addr,omitempty" koanf:"dest_addr,omitempty"`
-	Port       *int           `yaml:"port,omitempty" koanf:"port,omitempty"`
-	Protocol   *string        `yaml:"protocol,omitempty" koanf:"protocol,omitempty"`
-	URI        *string        `yaml:"uri,omitempty" koanf:"uri,omitempty"`
-	Version    *string        `yaml:"version,omitempty" koanf:"version,omitempty"`
-	Headers    ftwhttp.Header `yaml:"headers,omitempty" koanf:"headers,omitempty"`
-	Method     *string        `yaml:"method,omitempty" koanf:"method,omitempty"`
-	Data       *string        `yaml:"data,omitempty" koanf:"data,omitempty"`
-	SaveCookie *bool          `yaml:"save_cookie,omitempty" koanf:"save_cookie,omitempty"`
-	// Deprecated: replaced with AutocompleteHeaders
-	StopMagic               *bool   `yaml:"stop_magic" koanf:"stop_magic,omitempty"`
-	AutocompleteHeaders     *bool   `yaml:"autocomplete_headers" koanf:"autocomplete_headers,omitempty"`
-	EncodedRequest          *string `yaml:"encoded_request,omitempty" koanf:"encoded_request,omitempty"`
-	RAWRequest              *string `yaml:"raw_request,omitempty" koanf:"raw_request,omitempty"`
-	OverrideEmptyHostHeader *bool   `yaml:"override_empty_host_header,omitempty" koanf:"override_empty_host_header,omitempty"`
-}
-
 // ApplyInputOverride will check if config had global overrides and write that into the test.
-func ApplyInputOverrides(overrides *Overrides, input *Input) {
+func ApplyInputOverrides(conf *config.FTWConfiguration, input *Input) {
+	overrides := &conf.TestOverride.Overrides
 	applySimpleOverrides(overrides, input)
 	applyDestAddrOverride(overrides, input)
 	applyHeadersOverride(overrides, input)
+	//nolint:staticcheck
 	if overrides.AutocompleteHeaders != nil || overrides.StopMagic != nil {
+		//nolint:staticcheck
 		postProcessAutocompleteHeaders(overrides.AutocompleteHeaders, overrides.StopMagic, input)
 	}
 }
 
-func applyDestAddrOverride(overrides *Overrides, input *Input) {
+func ApplyPlatformOverrides(conf *config.FTWConfiguration, testCase *schema.Test) {
+	platformOverrides := conf.PlatformOverrides
+	log.Debug().Msgf("Applying overrides for engine '%s', platform '%s", platformOverrides.Meta.Engine, platformOverrides.Meta.Platform)
+	overrides, ok := platformOverrides.OverridesMap[testCase.RuleId]
+	if !ok {
+		log.Trace().Msgf("no override found for rule %d", testCase.RuleId)
+		return
+	}
+
+	applyToAll := len(overrides) == 0
+	for _, override := range overrides {
+		for _, testId := range override.TestIds {
+			if applyToAll || testId == testCase.TestId {
+				basicApplyPlatformOverrides(override, testCase)
+			}
+		}
+	}
+}
+
+func basicApplyPlatformOverrides(override *overridesSchema.TestOverride, testCase *schema.Test) {
+	// Apply to all stages of the given test if the list of stage IDs is empty
+	applyToAll := len(override.StageIds) == 0
+
+	for index := 0; index < len(testCase.Stages); index++ {
+		stage := &testCase.Stages[index]
+		if applyToAll || slices.Contains(override.StageIds, uint(index)) {
+			stage.Output = override.Output
+		}
+	}
+}
+
+func applyDestAddrOverride(overrides *config.Overrides, input *Input) {
 	if overrides.DestAddr != nil {
 		input.DestAddr = overrides.DestAddr
 		if input.Headers == nil {
@@ -51,7 +73,7 @@ func applyDestAddrOverride(overrides *Overrides, input *Input) {
 	}
 }
 
-func applySimpleOverrides(overrides *Overrides, input *Input) {
+func applySimpleOverrides(overrides *config.Overrides, input *Input) {
 	if overrides.Port != nil {
 		input.Port = overrides.Port
 	}
@@ -85,11 +107,12 @@ func applySimpleOverrides(overrides *Overrides, input *Input) {
 	}
 
 	if overrides.RAWRequest != nil {
+		//nolint:staticcheck
 		input.RAWRequest = *overrides.RAWRequest
 	}
 }
 
-func applyHeadersOverride(overrides *Overrides, input *Input) {
+func applyHeadersOverride(overrides *config.Overrides, input *Input) {
 	if overrides.Headers != nil {
 		if input.Headers == nil {
 			input.Headers = ftwhttp.Header{}
@@ -100,23 +123,52 @@ func applyHeadersOverride(overrides *Overrides, input *Input) {
 	}
 }
 
-func postLoadTestFTWTest(ftwTest *FTWTest) {
-	for _, test := range ftwTest.Tests {
-		postLoadTest(&test)
+func postLoadTestFTWTest(ftwTest *FTWTest, fileName string) {
+	ftwTest.FileName = fileName
+	postLoadRuleId(ftwTest)
+	for index := 0; index < len(ftwTest.Tests); index++ {
+		postLoadTest(ftwTest.RuleId, uint(index+1), &ftwTest.Tests[index])
 	}
 }
 
-func postLoadTest(test *types.Test) {
+func postLoadRuleId(ftwTest *FTWTest) {
+	if ftwTest.RuleId > 0 {
+		return
+	}
+
+	if len(ftwTest.FileName) == 0 {
+		log.Fatal().Msg("The rule_id field is required for the top-level test structure")
+	} else {
+		ruleIdString := regexp.MustCompile(`\d+`).FindString(ftwTest.FileName)
+		if len(ruleIdString) == 0 {
+			log.Fatal().Msg("Failed to fall back on filename to find rule ID of test. The rule_id field is required for the top-level test structure")
+			return
+		}
+		ruleId, err := strconv.ParseUint(ruleIdString, 10, 0)
+		if err != nil {
+			log.Fatal().Msgf("failed to parse rule ID from filename '%s'", ftwTest.FileName)
+			return
+		}
+		ftwTest.RuleId = uint(ruleId)
+	}
+}
+func postLoadTest(ruleId uint, testId uint, test *schema.Test) {
+	test.RuleId = ruleId
+	// Retain explicitly defined test IDs
+	if test.TestId == 0 {
+		test.TestId = testId
+	}
 	for index := range test.Stages {
-		postLoadStage(&test.Stages[index].SD)
+		postLoadStage(&test.Stages[index])
 	}
 }
 
-func postLoadStage(stage *types.StageData) {
+func postLoadStage(stage *schema.Stage) {
 	postLoadInput((*Input)(&stage.Input))
 }
 
 func postLoadInput(input *Input) {
+	//nolint:staticcheck
 	postProcessAutocompleteHeaders(input.AutocompleteHeaders, input.StopMagic, input)
 }
 
@@ -134,5 +186,6 @@ func postProcessAutocompleteHeaders(autocompleteHeaders *bool, stopMagic *bool,
 	}
 	input.AutocompleteHeaders = &finalValue
 	// StopMagic has the inverse boolean logic
+	//nolint:staticcheck
 	input.StopMagic = func() *bool { b := !finalValue; return &b }()
 }
diff --git a/test/types_test.go b/test/types_test.go
index 94ffa20..9ef013f 100644
--- a/test/types_test.go
+++ b/test/types_test.go
@@ -23,48 +23,46 @@ meta:
   enabled: true
   name: "gotest-ftw.yaml"
   description: "Example Test"
+rule_id: 123456
 tests:
-  - test_title: "001"
+  - test_id: 1
     description: "autocomplete headers by default"
     stages:
-      - stage:
-          input:
-            dest_addr: "localhost"
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "localhost"
-          output:
-            expect_error: False
-            status: [200]
-  - test_title: "002"
+      - input:
+          dest_addr: "localhost"
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "localhost"
+        output:
+          expect_error: False
+          status: 200
+  - test_id: 2
     description: "autocomplete headers by default"
     stages:
-      - stage:
-          input:
-            stop_magic: true
-            dest_addr: "localhost"
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "localhost"
-          output:
-            expect_error: False
-            status: [200]
-  - test_title: "003"
+      - input:
+          stop_magic: true
+          dest_addr: "localhost"
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "localhost"
+        output:
+          expect_error: False
+          status: 200
+  - test_id: 3
     description: "autocomplete headers by default"
     stages:
-      - stage:
-          input:
-            stop_magic: false
-            dest_addr: "localhost"
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "localhost"
-          output:
-            expect_error: False
-            status: [200]
+      - input:
+          stop_magic: false
+          dest_addr: "localhost"
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "localhost"
+        output:
+          expect_error: False
+          status: 200
 `
 
 var autocompleteHeadersFalseYaml = `---
@@ -73,51 +71,49 @@ meta:
   enabled: true
   name: "gotest-ftw.yaml"
   description: "Example Test"
+rule_id: 123456
 tests:
-  - test_title: "001"
+  - test_id: 1
     description: "autocomplete headers explicitly"
     stages:
-      - stage:
-          input:
-            autocomplete_headers: false
-            dest_addr: "localhost"
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "localhost"
-          output:
-            expect_error: False
-            status: [200]
-  - test_title: "002"
+      - input:
+          autocomplete_headers: false
+          dest_addr: "localhost"
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "localhost"
+        output:
+          expect_error: False
+          status: 200
+  - test_id: 2
     description: "autocomplete headers explicitly"
     stages:
-      - stage:
-          input:
-            autocomplete_headers: false
-            stop_magic: true
-            dest_addr: "localhost"
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "localhost"
-          output:
-            expect_error: False
-            status: [200]
-  - test_title: "003"
+      - input:
+          autocomplete_headers: false
+          stop_magic: true
+          dest_addr: "localhost"
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "localhost"
+        output:
+          expect_error: False
+          status: 200
+  - test_id: 3
     description: "autocomplete headers explicitly"
     stages:
-      - stage:
-          input:
-            autocomplete_headers: false
-            stop_magic: false
-            dest_addr: "localhost"
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "localhost"
-          output:
-            expect_error: False
-            status: [200]
+      - input:
+          autocomplete_headers: false
+          stop_magic: false
+          dest_addr: "localhost"
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "localhost"
+        output:
+          expect_error: False
+          status: 200
 `
 
 var autocompleteHeadersTrueYaml = `---
@@ -126,129 +122,136 @@ meta:
   enabled: true
   name: "gotest-ftw.yaml"
   description: "Example Test"
+rule_id: 123456
 tests:
-  - test_title: "001"
+  - test_id: 1
     description: "do not autocomplete headers explicitly"
     stages:
-      - stage:
-          input:
-            autocomplete_headers: true
-            dest_addr: "localhost"
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "localhost"
-          output:
-            expect_error: False
-            status: [200]
-  - test_title: "002"
+      - input:
+          autocomplete_headers: true
+          dest_addr: "localhost"
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "localhost"
+        output:
+          expect_error: False
+          status: 200
+  - test_id: 2
     description: "do not autocomplete headers explicitly"
     stages:
-      - stage:
-          input:
-            autocomplete_headers: true
-            stop_magic: true
-            dest_addr: "localhost"
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "localhost"
-          output:
-            expect_error: False
-            status: [200]
-  - test_title: "003"
+      - input:
+          autocomplete_headers: true
+          stop_magic: true
+          dest_addr: "localhost"
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "localhost"
+        output:
+          expect_error: False
+          status: 200
+  - test_id: 3
     description: "do not autocomplete headers explicitly"
     stages:
-      - stage:
-          input:
-            autocomplete_headers: true
-            stop_magic: false
-            dest_addr: "localhost"
-            headers:
-              User-Agent: "ModSecurity CRS 3 Tests"
-              Accept: "*/*"
-              Host: "localhost"
-          output:
-            expect_error: False
-            status: [200]
+      - input:
+          autocomplete_headers: true
+          stop_magic: false
+          dest_addr: "localhost"
+          headers:
+            User-Agent: "ModSecurity CRS 3 Tests"
+            Accept: "*/*"
+            Host: "localhost"
+        output:
+          expect_error: False
+          status: 200
 `
 
 func (s *typesTestSuite) TestAutocompleteHeadersDefault_StopMagicDefault() {
-	test, err := GetTestFromYaml([]byte(autocompleteHeadersDefaultYaml))
+	test, err := GetTestFromYaml([]byte(autocompleteHeadersDefaultYaml), "")
 	s.NoError(err, "Parsing YAML shouldn't fail")
 
-	input := test.Tests[0].Stages[0].SD.Input
+	input := test.Tests[0].Stages[0].Input
 	s.True(*input.AutocompleteHeaders)
+	//nolint:staticcheck
 	s.False(*input.StopMagic)
 }
 
 func (s *typesTestSuite) TestAutocompleteHeadersDefault_StopMagicTrue() {
-	test, err := GetTestFromYaml([]byte(autocompleteHeadersDefaultYaml))
+	test, err := GetTestFromYaml([]byte(autocompleteHeadersDefaultYaml), "")
 	s.NoError(err, "Parsing YAML shouldn't fail")
 
-	input := test.Tests[1].Stages[0].SD.Input
+	input := test.Tests[1].Stages[0].Input
 	s.False(*input.AutocompleteHeaders)
+	//nolint:staticcheck
 	s.True(*input.StopMagic)
 }
 func (s *typesTestSuite) TestAutocompleteHeadersDefault_StopMagicFalse() {
-	test, err := GetTestFromYaml([]byte(autocompleteHeadersDefaultYaml))
+	test, err := GetTestFromYaml([]byte(autocompleteHeadersDefaultYaml), "")
 	s.NoError(err, "Parsing YAML shouldn't fail")
 
-	input := test.Tests[2].Stages[0].SD.Input
+	input := test.Tests[2].Stages[0].Input
 	s.True(*input.AutocompleteHeaders)
+	//nolint:staticcheck
 	s.False(*input.StopMagic)
 }
 
 func (s *typesTestSuite) TestAutocompleteHeadersFalse_StopMagicDefault() {
-	test, err := GetTestFromYaml([]byte(autocompleteHeadersFalseYaml))
+	test, err := GetTestFromYaml([]byte(autocompleteHeadersFalseYaml), "")
 	s.NoError(err, "Parsing YAML shouldn't fail")
 
-	input := test.Tests[0].Stages[0].SD.Input
+	input := test.Tests[0].Stages[0].Input
 	s.False(*input.AutocompleteHeaders)
+	//nolint:staticcheck
 	s.True(*input.StopMagic)
 }
 
 func (s *typesTestSuite) TestAutocompleteHeadersFalse_StopMagicTrue() {
-	test, err := GetTestFromYaml([]byte(autocompleteHeadersFalseYaml))
+	test, err := GetTestFromYaml([]byte(autocompleteHeadersFalseYaml), "")
 	s.NoError(err, "Parsing YAML shouldn't fail")
 
-	input := test.Tests[1].Stages[0].SD.Input
+	input := test.Tests[1].Stages[0].Input
 	s.False(*input.AutocompleteHeaders)
+	//nolint:staticcheck
 	s.True(*input.StopMagic)
 }
 
 func (s *typesTestSuite) TestAutocompleteHeadersFalse_StopMagicFalse() {
-	test, err := GetTestFromYaml([]byte(autocompleteHeadersFalseYaml))
+	test, err := GetTestFromYaml([]byte(autocompleteHeadersFalseYaml), "")
 	s.NoError(err, "Parsing YAML shouldn't fail")
 
-	input := test.Tests[2].Stages[0].SD.Input
+	input := test.Tests[2].Stages[0].Input
 	s.False(*input.AutocompleteHeaders)
+	//nolint:staticcheck
 	s.True(*input.StopMagic)
 }
 
 func (s *typesTestSuite) TestAutocompleteHeadersTrue_StopMagicDefault() {
-	test, err := GetTestFromYaml([]byte(autocompleteHeadersTrueYaml))
+	test, err := GetTestFromYaml([]byte(autocompleteHeadersTrueYaml), "")
 	s.NoError(err, "Parsing YAML shouldn't fail")
 
-	input := test.Tests[0].Stages[0].SD.Input
+	input := test.Tests[0].Stages[0].Input
 	s.True(*input.AutocompleteHeaders)
+	//nolint:staticcheck
 	s.False(*input.StopMagic)
 }
 
 func (s *typesTestSuite) TestAutocompleteHeadersTrue_StopMagicTrue() {
-	test, err := GetTestFromYaml([]byte(autocompleteHeadersTrueYaml))
+	test, err := GetTestFromYaml([]byte(autocompleteHeadersTrueYaml), "")
 	s.NoError(err, "Parsing YAML shouldn't fail")
 
-	input := test.Tests[1].Stages[0].SD.Input
+	input := test.Tests[1].Stages[0].Input
 	s.True(*input.AutocompleteHeaders)
+	//nolint:staticcheck
 	s.False(*input.StopMagic)
 }
 
 func (s *typesTestSuite) TestAutocompleteHeadersTrue_StopMagicFalse() {
-	test, err := GetTestFromYaml([]byte(autocompleteHeadersTrueYaml))
+	test, err := GetTestFromYaml([]byte(autocompleteHeadersTrueYaml), "")
 	s.NoError(err, "Parsing YAML shouldn't fail")
 
-	input := test.Tests[2].Stages[0].SD.Input
+	input := test.Tests[2].Stages[0].Input
 	s.True(*input.AutocompleteHeaders)
+	//nolint:staticcheck
 	s.False(*input.StopMagic)
 }
diff --git a/test/yaml.go b/test/yaml.go
index 8bf09d1..b8fd890 100644
--- a/test/yaml.go
+++ b/test/yaml.go
@@ -10,14 +10,14 @@ import (
 )
 
 // GetTestFromYaml will get the tests to be processed from a YAML string.
-func GetTestFromYaml(testYaml []byte) (ftwTest *FTWTest, err error) {
+func GetTestFromYaml(testYaml []byte, fileName string) (ftwTest *FTWTest, err error) {
 	ftwTest = &FTWTest{}
 	err = yaml.Unmarshal(testYaml, ftwTest)
 	if err != nil {
 		return &FTWTest{}, err
 	}
 
-	postLoadTestFTWTest(ftwTest)
+	postLoadTestFTWTest(ftwTest, fileName)
 
 	return ftwTest, nil
 }
@@ -33,7 +33,7 @@ func DescribeYamlError(yamlError error) string {
 			"A simple example would be like this:\n\n" +
 			"status: 403\n" +
 			"needs to be changed to:\n\n" +
-			"status: [403]\n\n"
+			"status: 403\n\n"
 	}
 	matched, err = regexp.MatchString(`.*cannot unmarshal \[]interface {} into Go struct field FTWTest.Tests of type string.*`, yamlError.Error())
 	if err != nil {