diff --git a/check/base.go b/check/base.go index b0a8456..f1087d3 100644 --- a/check/base.go +++ b/check/base.go @@ -6,7 +6,6 @@ import ( "github.com/fzipi/go-ftw/config" "github.com/fzipi/go-ftw/test" "github.com/fzipi/go-ftw/waflog" - "github.com/rs/zerolog/log" ) // FTWCheck is the base struct for checking test results @@ -32,9 +31,6 @@ func NewCheck(c *config.FTWConfiguration) *FTWCheck { overrides: &c.TestOverride, } - log.Trace().Msgf("check/base: time will be truncated to %s", check.log.TimeTruncate) - log.Trace().Msgf("check/base: logfile will be truncated? %t", check.log.LogTruncate) - return check } @@ -91,3 +87,22 @@ func (c *FTWCheck) ForcedFail(id string) bool { _, ok := c.overrides.ForceFail[id] return ok } + +// CloudMode returns true if we are running in cloud mode +func (c *FTWCheck) CloudMode() bool { + return c.overrides.Mode == config.CloudMode +} + +// 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 +} diff --git a/check/base_test.go b/check/base_test.go index f744330..8616e7b 100644 --- a/check/base_test.go +++ b/check/base_test.go @@ -1,14 +1,15 @@ package check import ( + "sort" "testing" "time" "github.com/fzipi/go-ftw/config" + "github.com/fzipi/go-ftw/test" ) -var yamlApacheConfig = ` ---- +var yamlApacheConfig = `--- logfile: 'tests/logs/modsec2-apache/apache2/error.log' logtype: name: 'apache' @@ -16,8 +17,7 @@ logtype: timeformat: 'ddd MMM DD HH:mm:ss.S YYYY' ` -var yamlNginxConfig = ` ---- +var yamlNginxConfig = `--- logfile: 'tests/logs/modsec3-nginx/nginx/error.log' logtype: name: 'nginx' @@ -29,8 +29,16 @@ testoverride: '942200-1': 'Ignore Me' ` +var yamlCloudConfig = `--- +testoverride: + mode: "cloud" +` + func TestNewCheck(t *testing.T) { - config.ImportFromString(yamlNginxConfig) + err := config.NewConfigFromString(yamlNginxConfig) + if err != nil { + t.Error(err) + } c := NewCheck(config.FTWConfig) @@ -43,10 +51,32 @@ func TestNewCheck(t *testing.T) { t.Errorf("Well, didn't match Ignore Me") } } + + to := test.Output{ + Status: []int{200}, + ResponseContains: "", + LogContains: "nothing", + NoLogContains: "", + ExpectError: true, + } + c.SetExpectTestOutput(&to) + + if c.expected.ExpectError != true { + t.Error("Problem setting expected output") + } + + c.SetNoLogContains("nologcontains") + + if c.expected.NoLogContains != "nologcontains" { + t.Error("PRoblem setting nologcontains") + } } func TestForced(t *testing.T) { - config.ImportFromString(yamlNginxConfig) + err := config.NewConfigFromString(yamlNginxConfig) + if err != nil { + t.Error(err) + } c := NewCheck(config.FTWConfig) @@ -55,10 +85,53 @@ func TestForced(t *testing.T) { } if c.ForcedFail("1245") { - t.Errorf("Valued should not be found") + t.Errorf("Value should not be found") } if c.ForcedPass("1245") { - t.Errorf("Valued should not be found") + t.Errorf("Value should not be found") + } +} + +func TestCloudMode(t *testing.T) { + err := config.NewConfigFromString(yamlCloudConfig) + if err != nil { + t.Error(err) + } + + c := NewCheck(config.FTWConfig) + + if c.CloudMode() != true { + t.Errorf("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) + if res := sort.SearchInts(cloudStatus, 403); res == 0 { + t.Errorf("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 + } + } + if !found { + t.Errorf("couldn't find expected 200 status\n") + } + } diff --git a/check/error_test.go b/check/error_test.go index 2ff8d1d..5e968cc 100644 --- a/check/error_test.go +++ b/check/error_test.go @@ -24,8 +24,11 @@ var expectedFailTests = []struct { } func TestAssertResponseErrorOK(t *testing.T) { - config.ImportFromString(yamlApacheConfig) + err := config.NewConfigFromString(yamlApacheConfig) + if err != nil { + t.Errorf("Failed!") + } c := NewCheck(config.FTWConfig) for _, e := range expectedOKTests { c.SetExpectError(e.expected) @@ -36,7 +39,11 @@ func TestAssertResponseErrorOK(t *testing.T) { } func TestAssertResponseFail(t *testing.T) { - config.ImportFromString(yamlApacheConfig) + err := config.NewConfigFromString(yamlApacheConfig) + + if err != nil { + t.Errorf("Failed!") + } c := NewCheck(config.FTWConfig) diff --git a/check/logs.go b/check/logs.go index 49f70f4..1b7aaf5 100644 --- a/check/logs.go +++ b/check/logs.go @@ -1,7 +1,5 @@ package check -import "github.com/rs/zerolog/log" - // AssertNoLogContains returns true is the string is not found in the logs func (c *FTWCheck) AssertNoLogContains() bool { if c.expected.NoLogContains != "" { @@ -12,9 +10,7 @@ func (c *FTWCheck) AssertNoLogContains() bool { // AssertLogContains returns true when the logs contain the string func (c *FTWCheck) AssertLogContains() bool { - log.Trace().Msgf("ftw/check: check will truncate at %s", c.log.TimeTruncate) if c.expected.LogContains != "" { - log.Debug().Msgf("ftw/check: log contains? -> %s", c.expected.LogContains) return c.log.Contains(c.expected.LogContains) } return false diff --git a/check/logs_test.go b/check/logs_test.go index 4de233f..16f525d 100644 --- a/check/logs_test.go +++ b/check/logs_test.go @@ -8,8 +8,7 @@ import ( "github.com/fzipi/go-ftw/utils" ) -var nginxLogText = ` -2021/03/16 12:40:19 [info] 17#17: *2495 ModSecurity: Warning. Matched "Operator ` + "`" + `Within' with parameter ` + "`" + `GET HEAD POST OPTIONS' against variable ` + "`" + `REQUEST_METHOD' (Value: ` + "`" + `OTHER' ) [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-911-METHOD-ENFORCEMENT.conf"] [line "27"] [id "911100"] [rev ""] [msg "Method is not allowed by policy"] [data "OTHER"] [severity "2"] [ver "OWASP_CRS/3.3.0"] [maturity "0"] [accuracy "0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-generic"] [tag "paranoia-level/1"] [tag "OWASP_CRS"] [tag "capec/1000/210/272/220/274"] [tag "PCI/12.1"] [hostname "172.19.0.3"] [uri "/"] [unique_id "161589841954.023243"] [ref "v0,5"], client: 172.19.0.1, server: modsec3-nginx, request: "OTHER / HTTP/1.1", host: "localhost" +var nginxLogText = `2021/03/16 12:40:19 [info] 17#17: *2495 ModSecurity: Warning. Matched "Operator ` + "`" + `Within' with parameter ` + "`" + `GET HEAD POST OPTIONS' against variable ` + "`" + `REQUEST_METHOD' (Value: ` + "`" + `OTHER' ) [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-911-METHOD-ENFORCEMENT.conf"] [line "27"] [id "911100"] [rev ""] [msg "Method is not allowed by policy"] [data "OTHER"] [severity "2"] [ver "OWASP_CRS/3.3.0"] [maturity "0"] [accuracy "0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-generic"] [tag "paranoia-level/1"] [tag "OWASP_CRS"] [tag "capec/1000/210/272/220/274"] [tag "PCI/12.1"] [hostname "172.19.0.3"] [uri "/"] [unique_id "161589841954.023243"] [ref "v0,5"], client: 172.19.0.1, server: modsec3-nginx, request: "OTHER / HTTP/1.1", host: "localhost" 2021/03/16 12:40:19 [info] 17#17: *2495 ModSecurity: Warning. Matched "Operator ` + "`" + `Pm' with parameter ` + "`" + `AppleWebKit Android' against variable ` + "`" + `REQUEST_HEADERS:User-Agent' (Value: ` + "`" + `ModSecurity CRS 3 Tests' ) [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-920-PROTOCOL-ENFORCEMENT.conf"] [line "1360"] [id "920300"] [rev ""] [msg "Request Missing an Accept Header"] [data ""] [severity "5"] [ver "OWASP_CRS/3.3.0"] [maturity "0"] [accuracy "0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-protocol"] [tag "OWASP_CRS"] [tag "capec/1000/210/272"] [tag "PCI/6.5.10"] [tag "paranoia-level/3"] [hostname "172.19.0.3"] [uri "/"] [unique_id "161589841954.023243"] [ref "v0,5v63,23"], client: 172.19.0.1, server: modsec3-nginx, request: "OTHER / HTTP/1.1", host: "localhost" 2021/03/16 12:40:19 [info] 17#17: *2495 ModSecurity: Warning. Matched "Operator ` + "`" + `Ge' with parameter ` + "`" + `5' against variable ` + "`" + `TX:ANOMALY_SCORE' (Value: ` + "`" + `7' ) [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-949-BLOCKING-EVALUATION.conf"] [line "138"] [id "949110"] [rev ""] [msg "Inbound Anomaly Score Exceeded (Total Score: 7)"] [data ""] [severity "2"] [ver "OWASP_CRS/3.3.0"] [maturity "0"] [accuracy "0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-generic"] [hostname "172.19.0.3"] [uri "/"] [unique_id "161589841954.023243"] [ref ""], client: 172.19.0.1, server: modsec3-nginx, request: "OTHER / HTTP/1.1", host: "localhost" 2021/03/16 12:40:19 [info] 17#17: *2497 ModSecurity: Warning. Matched "Operator ` + "`" + `Within' with parameter ` + "`" + `GET HEAD POST OPTIONS' against variable ` + "`" + `REQUEST_METHOD' (Value: ` + "`" + `OTHER' ) [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-911-METHOD-ENFORCEMENT.conf"] [line "27"] [id "911100"] [rev ""] [msg "Method is not allowed by policy"] [data "OTHER"] [severity "2"] [ver "OWASP_CRS/3.3.0"] [maturity "0"] [accuracy "0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-generic"] [tag "paranoia-level/1"] [tag "OWASP_CRS"] [tag "capec/1000/210/272/220/274"] [tag "PCI/12.1"] [hostname "172.19.0.3"] [uri "/"] [unique_id "161589841970.216949"] [ref "v0,5"], client: 172.19.0.1, server: modsec3-nginx, request: "OTHER / HTTP/1.1", host: "localhost" @@ -17,15 +16,17 @@ var nginxLogText = ` 2021/03/16 12:40:19 [info] 17#17: *2497 ModSecurity: Warning. Matched "Operator ` + "`" + `Ge' with parameter ` + "`" + `5' against variable ` + "`" + `TX:ANOMALY_SCORE' (Value: ` + "`" + `7' ) [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-949-BLOCKING-EVALUATION.conf"] [line "138"] [id "949110"] [rev ""] [msg "Inbound Anomaly Score Exceeded (Total Score: 7)"] [data ""] [severity "2"] [ver "OWASP_CRS/3.3.0"] [maturity "0"] [accuracy "0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-generic"] [hostname "172.19.0.3"] [uri "/"] [unique_id "161589841970.216949"] [ref ""], client: 172.19.0.1, server: modsec3-nginx, request: "OTHER / HTTP/1.1", host: "localhost" ` -var apacheLogText = ` -[Tue Jan 05 02:21:09.637165 2021] [:error] [pid 76:tid 139683434571520] [client 172.23.0.1:58998] [client 172.23.0.1] ModSecurity: Warning. Pattern match "\\\\b(?:keep-alive|close),\\\\s?(?:keep-alive|close)\\\\b" at REQUEST_HEADERS:Connection. [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-920-PROTOCOL-ENFORCEMENT.conf"] [line "339"] [id "920210"] [msg "Multiple/Conflicting Connection Header Data Found"] [data "close,close"] [severity "WARNING"] [ver "OWASP_CRS/3.3.0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-protocol"] [tag "paranoia-level/1"] [tag "OWASP_CRS"] [tag "capec/1000/210/272"] [hostname "localhost"] [uri "/"] [unique_id "X-PNFSe1VwjCgYRI9FsbHgAAAIY"] +var apacheLogText = `[Tue Jan 05 02:21:09.637165 2021] [:error] [pid 76:tid 139683434571520] [client 172.23.0.1:58998] [client 172.23.0.1] ModSecurity: Warning. Pattern match "\\\\b(?:keep-alive|close),\\\\s?(?:keep-alive|close)\\\\b" at REQUEST_HEADERS:Connection. [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-920-PROTOCOL-ENFORCEMENT.conf"] [line "339"] [id "920210"] [msg "Multiple/Conflicting Connection Header Data Found"] [data "close,close"] [severity "WARNING"] [ver "OWASP_CRS/3.3.0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-protocol"] [tag "paranoia-level/1"] [tag "OWASP_CRS"] [tag "capec/1000/210/272"] [hostname "localhost"] [uri "/"] [unique_id "X-PNFSe1VwjCgYRI9FsbHgAAAIY"] [Tue Jan 05 02:21:09.637731 2021] [:error] [pid 76:tid 139683434571520] [client 172.23.0.1:58998] [client 172.23.0.1] ModSecurity: Warning. Match of "pm AppleWebKit Android" against "REQUEST_HEADERS:User-Agent" required. [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-920-PROTOCOL-ENFORCEMENT.conf"] [line "1230"] [id "920300"] [msg "Request Missing an Accept Header"] [severity "NOTICE"] [ver "OWASP_CRS/3.3.0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-protocol"] [tag "OWASP_CRS"] [tag "capec/1000/210/272"] [tag "PCI/6.5.10"] [tag "paranoia-level/2"] [hostname "localhost"] [uri "/"] [unique_id "X-PNFSe1VwjCgYRI9FsbHgAAAIY"] [Tue Jan 05 02:21:09.638572 2021] [:error] [pid 76:tid 139683434571520] [client 172.23.0.1:58998] [client 172.23.0.1] ModSecurity: Warning. Operator GE matched 5 at TX:anomaly_score. [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-949-BLOCKING-EVALUATION.conf"] [line "91"] [id "949110"] [msg "Inbound Anomaly Score Exceeded (Total Score: 5)"] [severity "CRITICAL"] [ver "OWASP_CRS/3.3.0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-generic"] [hostname "localhost"] [uri "/"] [unique_id "X-PNFSe1VwjCgYRI9FsbHgAAAIY"] [Tue Jan 05 02:21:09.647668 2021] [:error] [pid 76:tid 139683434571520] [client 172.23.0.1:58998] [client 172.23.0.1] ModSecurity: Warning. Operator GE matched 5 at TX:inbound_anomaly_score. [file "/etc/modsecurity.d/owasp-crs/rules/RESPONSE-980-CORRELATION.conf"] [line "87"] [id "980130"] [msg "Inbound Anomaly Score Exceeded (Total Inbound Score: 5 - SQLI=0,XSS=0,RFI=0,LFI=0,RCE=0,PHPI=0,HTTP=0,SESS=0): individual paranoia level scores: 3, 2, 0, 0"] [ver "OWASP_CRS/3.3.0"] [tag "event-correlation"] [hostname "localhost"] [uri "/"] [unique_id "X-PNFSe1VwjCgYRI9FsbHgAAAIY"] ` func TestAssertApacheLogContainsOK(t *testing.T) { - config.ImportFromString(yamlApacheConfig) + err := config.NewConfigFromString(yamlApacheConfig) + if err != nil { + t.Errorf("Failed!") + } logName, _ := utils.CreateTempFileWithContent(apacheLogText, "test-apache-*.log") defer os.Remove(logName) config.FTWConfig.LogFile = logName @@ -50,7 +51,10 @@ func TestAssertApacheLogContainsOK(t *testing.T) { } func TestAssertNginxLogContainsOK(t *testing.T) { - config.ImportFromString(yamlNginxConfig) + err := config.NewConfigFromString(yamlNginxConfig) + if err != nil { + t.Errorf("Failed!") + } logName, _ := utils.CreateTempFileWithContent(nginxLogText, "test-nginx-*.log") defer os.Remove(logName) config.FTWConfig.LogFile = logName @@ -66,4 +70,8 @@ func TestAssertNginxLogContainsOK(t *testing.T) { if !c.AssertLogContains() { t.Errorf("Failed !") } + + if c.AssertNoLogContains() { + t.Error("No log contains failed") + } } diff --git a/check/response.go b/check/response.go index 110825c..239162b 100644 --- a/check/response.go +++ b/check/response.go @@ -2,14 +2,11 @@ package check import ( "strings" - - "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 != "" { - log.Trace().Msgf("ftw/check: is %s contained in response %s", c.expected.ResponseContains, response) return strings.Contains(response, c.expected.ResponseContains) } return false diff --git a/check/response_test.go b/check/response_test.go index 6a3a238..a78d87c 100644 --- a/check/response_test.go +++ b/check/response_test.go @@ -21,8 +21,11 @@ var expectedResponseFailTests = []struct { } func TestAssertResponseTextErrorOK(t *testing.T) { - config.ImportFromString(yamlApacheConfig) + err := config.NewConfigFromString(yamlApacheConfig) + if err != nil { + t.Errorf("Failed!") + } c := NewCheck(config.FTWConfig) for _, e := range expectedResponseOKTests { c.SetExpectResponse(e.expected) @@ -33,8 +36,10 @@ func TestAssertResponseTextErrorOK(t *testing.T) { } func TestAssertResponseTextFailOK(t *testing.T) { - config.ImportFromString(yamlApacheConfig) - + err := config.NewConfigFromString(yamlApacheConfig) + if err != nil { + t.Errorf("Failed!") + } c := NewCheck(config.FTWConfig) for _, e := range expectedResponseFailTests { c.SetExpectResponse(e.expected) diff --git a/check/status.go b/check/status.go index 4ab1fe8..9fc6f0e 100644 --- a/check/status.go +++ b/check/status.go @@ -1,10 +1,7 @@ package check -import "github.com/rs/zerolog/log" - // AssertStatus will match the expected status list with the one received in the response func (c *FTWCheck) AssertStatus(status int) bool { - log.Trace().Msgf("ftw/check: status %d, expected %v", status, c.expected.Status) for _, i := range c.expected.Status { if i == status { return true diff --git a/check/status_test.go b/check/status_test.go index 0bea2af..d4b7df9 100644 --- a/check/status_test.go +++ b/check/status_test.go @@ -24,7 +24,10 @@ var statusFailTests = []struct { } func TestStatusOK(t *testing.T) { - config.ImportFromString(yamlApacheConfig) + err := config.NewConfigFromString(yamlApacheConfig) + if err != nil { + t.Errorf("Failed!") + } c := NewCheck(config.FTWConfig) @@ -37,7 +40,10 @@ func TestStatusOK(t *testing.T) { } func TestStatusFail(t *testing.T) { - config.ImportFromString(yamlApacheConfig) + err := config.NewConfigFromString(yamlApacheConfig) + if err != nil { + t.Errorf("Failed!") + } c := NewCheck(config.FTWConfig) diff --git a/cmd/root.go b/cmd/root.go index a8ce2f4..8271872 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,9 +1,10 @@ package cmd import ( + "log" "os" - config "github.com/fzipi/go-ftw/config" + "github.com/fzipi/go-ftw/config" "github.com/rs/zerolog" "github.com/spf13/cobra" @@ -42,7 +43,6 @@ func init() { } func initConfig() { - config.Init(cfgFile) zerolog.SetGlobalLevel(zerolog.InfoLevel) if debug { zerolog.SetGlobalLevel(zerolog.DebugLevel) @@ -50,4 +50,11 @@ func initConfig() { if trace { zerolog.SetGlobalLevel(zerolog.TraceLevel) } + errFile := config.NewConfigFromFile(cfgFile) + if errFile != nil { + errEnv := config.NewConfigFromEnv() + if errEnv != nil { + log.Fatalf("cannot read config from file (%s) nor environment (%s).", errFile.Error(), errEnv.Error()) + } + } } diff --git a/config/config.go b/config/config.go index f090926..b28ecc9 100644 --- a/config/config.go +++ b/config/config.go @@ -1,38 +1,76 @@ package config import ( - "time" + "os" + "strings" + + "github.com/knadh/koanf" + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/providers/rawbytes" ) -// FTWConfig is being exported to be used across the app -var FTWConfig *FTWConfiguration +// NewConfigFromFile reads configuration information from the config file if it exists, +// or uses `.ftw.yaml` as default file +func NewConfigFromFile(cfgFile string) error { + // Global koanf instance. Use "." as the key path delimiter. This can be "/" or any character. + var k = koanf.New(".") + var err error + + // first check if we had an explicit call with config file + if cfgFile == "" { + cfgFile = ".ftw.yaml" + } + + _, err = os.Stat(cfgFile) + if err != nil { // file exists, so we read it looking for config values + return err + } -// FTWConfiguration FTW global Configuration -type FTWConfiguration struct { - LogFile string `koanf:"logfile"` - LogType FTWLogType `koanf:"logtype"` - LogTruncate bool `koanf:"logtruncate"` - TestOverride FTWTestOverride `koanf:"testoverride"` + err = k.Load(file.Provider(cfgFile), yaml.Parser()) + if err != nil { + return err + } + + // At this point we have loaded our config, now we need to + // unmarshal the whole root module + err = k.UnmarshalWithConf("", &FTWConfig, koanf.UnmarshalConf{Tag: "koanf"}) + + return err } -// FTWLogType log readers must implement this one -// TimeTruncate is a string that represents a golang time, e.g. 'time.Microsecond', 'time.Second', etc. -// It will be used when comparing times to match logs -type FTWLogType struct { - Name string `koanf:"name"` - TimeRegex string `koanf:"timeregex"` - TimeFormat string `koanf:"timeformat"` - TimeTruncate time.Duration `koanf:"timetruncate"` +// NewConfigFromEnv reads configuration information from environment variables that start with `FTW_` +func NewConfigFromEnv() error { + var err error + var k = koanf.New(".") + + err = k.Load(env.Provider("FTW_", ".", func(s string) string { + return strings.ReplaceAll(strings.ToLower( + strings.TrimPrefix(s, "FTW_")), "_", ".") + }), nil) + + if err != nil { + return err + } + // Unmarshal the whole root module + err = k.UnmarshalWithConf("", &FTWConfig, koanf.UnmarshalConf{Tag: "koanf"}) + + return err } -// FTWTestOverride holds four lists: -// Global allows you to override global 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. -// Ignore is for tests you want to ignore. It will still execute the test, but ignore the results. You should add a comment on why you ignore the test -// ForcePass is for tests you want to pass unconditionally. Test will be executed, and pass even when the test fails. You should add a comment on why you force pass the test -// ForceFail is for tests you want to fail unconditionally. Test will be executed, and fail even when the test passes. You should add a comment on why you force fail the test -type FTWTestOverride struct { - Input map[string]string `koanf:"input"` - Ignore map[string]string `koanf:"ignore"` - ForcePass map[string]string `koanf:"forcepass"` - ForceFail map[string]string `koanf:"forcefail"` +// NewConfigFromString initializes the configuration from a yaml formatted string. Useful for testing. +func NewConfigFromString(conf string) error { + var k = koanf.New(".") + var err error + + err = k.Load(rawbytes.Provider([]byte(conf)), yaml.Parser()) + if err != nil { + return err + } + + // Unmarshal the whole root module + err = k.UnmarshalWithConf("", &FTWConfig, koanf.UnmarshalConf{Tag: "koanf"}) + + return err } diff --git a/config/config_test.go b/config/config_test.go index 0660c11..2fd30cb 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -15,6 +15,7 @@ logtype: name: 'apache' timeregex: '\[([A-Z][a-z]{2} [A-z][a-z]{2} \d{1,2} \d{1,2}\:\d{1,2}\:\d{1,2}\.\d+? \d{4})\]' timeformat: 'ddd MMM DD HH:mm:ss.S YYYY' +cloudmode: True testoverride: input: dest_addr: 'httpbin.org' @@ -46,16 +47,22 @@ var jsonConfig = ` {"test": "type"} ` -func TestInitBadFileConfig(t *testing.T) { +func TestNewConfigBadFileConfig(t *testing.T) { filename, _ := utils.CreateTempFileWithContent(jsonConfig, "test-*.yaml") defer os.Remove(filename) - Init(filename) + err := NewConfigFromFile(filename) + if err != nil { + t.Errorf("Failed!") + } } -func TestInitConfig(t *testing.T) { +func TestNewConfigConfig(t *testing.T) { filename, _ := utils.CreateTempFileWithContent(yamlConfig, "test-*.yaml") - Init(filename) + err := NewConfigFromFile(filename) + if err != nil { + t.Errorf("Failed!") + } if FTWConfig.LogType.Name != "apache" { t.Errorf("Failed !") @@ -90,29 +97,32 @@ func TestInitConfig(t *testing.T) { } -func TestInitBadConfig(t *testing.T) { +func TestNewConfigBadConfig(t *testing.T) { filename, _ := utils.CreateTempFileWithContent(yamlBadConfig, "test-*.yaml") defer os.Remove(filename) - Init(filename) + _ = NewConfigFromFile(filename) if FTWConfig == nil { t.Errorf("Failed !") } } -func TestInitDefaultConfig(t *testing.T) { +func TestNewConfigDefaultConfig(t *testing.T) { // For this test we need a local .ftw.yaml file _ = os.WriteFile(".ftw.yaml", []byte(yamlConfig), 0644) - Init("") + _ = NewConfigFromFile("") if FTWConfig == nil { t.Errorf("Failed !") } } -func TestImportConfig(t *testing.T) { - ImportFromString(yamlConfig) +func TestNewConfigFromString(t *testing.T) { + err := NewConfigFromString(yamlConfig) + if err != nil { + t.Errorf("Failed!") + } if FTWConfig.LogType.Name != "apache" { t.Errorf("Failed !") @@ -126,7 +136,10 @@ func TestImportConfig(t *testing.T) { func TestTimeTruncateConfig(t *testing.T) { filename, _ := utils.CreateTempFileWithContent(yamlTruncateConfig, "test-*.yaml") defer os.Remove(filename) - Init(filename) + err := NewConfigFromFile(filename) + if err != nil { + t.Errorf("Failed!") + } if FTWConfig.LogType.Name != "nginx" { t.Errorf("Failed !") @@ -144,8 +157,11 @@ func TestTimeTruncateConfig(t *testing.T) { } } -func TestImportConfigWithEnv(t *testing.T) { - ImportFromString(yamlConfig) +func TestNewEnvConfigFromString(t *testing.T) { + err := NewConfigFromString(yamlConfig) + if err != nil { + t.Errorf("Failed!") + } if FTWConfig.LogType.Name != "apache" { t.Errorf("Failed !") @@ -156,23 +172,17 @@ func TestImportConfigWithEnv(t *testing.T) { } } -func TestInitConfigWithEnv(t *testing.T) { - filename, _ := utils.CreateTempFileWithContent(yamlConfig, "test-env-*.yaml") - +func TestNewConfigFromEnv(t *testing.T) { // Set some environment so it gets merged with conf os.Setenv("FTW_LOGTYPE_NAME", "kaonf") - Init(filename) + err := NewConfigFromEnv() - if FTWConfig.LogType.Name != "kaonf" { - t.Errorf("Failed !") + if err != nil { + t.Error(err) } - if FTWConfig.LogType.TimeFormat != "ddd MMM DD HH:mm:ss.S YYYY" { - t.Errorf("Failed !") - } - - if len(FTWConfig.TestOverride.Ignore) == 0 { - t.Errorf("Failed! Len must be > 0") + if FTWConfig.LogType.Name != "kaonf" { + t.Errorf(FTWConfig.LogType.Name) } } diff --git a/config/init.go b/config/init.go deleted file mode 100644 index 075a249..0000000 --- a/config/init.go +++ /dev/null @@ -1,60 +0,0 @@ -package config - -import ( - "strings" - - "github.com/knadh/koanf" - "github.com/knadh/koanf/parsers/yaml" - "github.com/knadh/koanf/providers/env" - "github.com/knadh/koanf/providers/file" - "github.com/knadh/koanf/providers/rawbytes" - "github.com/rs/zerolog/log" -) - -// Init reads data from the config file and/or env vars -func Init(cfgFile string) { - log.Trace().Msgf("ftw/config: executing init") - // Global koanf instance. Use "." as the key path delimiter. This can be "/" or any character. - var k = koanf.New(".") - // first check if we had an explicit call with config file - if cfgFile == "" { - cfgFile = ".ftw.yaml" - } - - if err := k.Load(file.Provider(cfgFile), yaml.Parser()); err != nil { - log.Debug().Msgf("ftw/config: error reading config: %s", err.Error()) - } - - err := k.Load(env.Provider("FTW_", ".", func(s string) string { - return strings.ReplaceAll(strings.ToLower( - strings.TrimPrefix(s, "FTW_")), "_", ".") - }), nil) - - if err != nil { - log.Trace().Msgf("ftw/config: error while reading env vars: %s", err.Error()) - } - - // Unmarshal the whole root module - if err := k.UnmarshalWithConf("", &FTWConfig, koanf.UnmarshalConf{Tag: "koanf"}); err != nil { - log.Fatal().Msgf("ftw/config: error while unmarshaling config: %s", err.Error()) - } - - if duration := k.Duration("logtype.timetruncate"); duration != 0 { - log.Info().Msgf("ftw/config: will truncate logs to %s", duration) - } else { - log.Info().Msgf("ftw/config: no duration found") - } -} - -// ImportFromString initializes the configuration from a yaml formatted string. Useful for testing. -func ImportFromString(conf string) { - var k = koanf.New(".") - if err := k.Load(rawbytes.Provider([]byte(conf)), yaml.Parser()); err != nil { - log.Debug().Msgf("ftw/config: error reading config: %s", err.Error()) - } - - // Unmarshal the whole root module - if err := k.UnmarshalWithConf("", &FTWConfig, koanf.UnmarshalConf{Tag: "koanf"}); err != nil { - log.Fatal().Msgf("ftw/config: error while unmarshaling config: %s", err.Error()) - } -} diff --git a/config/types.go b/config/types.go new file mode 100644 index 0000000..d6c869b --- /dev/null +++ b/config/types.go @@ -0,0 +1,46 @@ +package config + +import ( + "time" +) + +const ( + // CloudMode is the string that will be used to override the mode of execution to cloud + CloudMode string = "cloud" + // DefaultMode is the default execution mode + DefaultMode string = "default" +) + +// FTWConfig is being exported to be used across the app +var FTWConfig *FTWConfiguration + +// FTWConfiguration FTW global Configuration +type FTWConfiguration struct { + LogFile string `koanf:"logfile"` + LogType FTWLogType `koanf:"logtype"` + LogTruncate bool `koanf:"logtruncate"` + TestOverride FTWTestOverride `koanf:"testoverride"` +} + +// FTWLogType log readers must implement this one +// TimeTruncate is a string that represents a golang time, e.g. 'time.Microsecond', 'time.Second', etc. +// It will be used when comparing times to match logs +type FTWLogType struct { + Name string `koanf:"name"` + TimeRegex string `koanf:"timeregex"` + TimeFormat string `koanf:"timeformat"` + TimeTruncate time.Duration `koanf:"timetruncate"` +} + +// FTWTestOverride holds four lists: +// Global allows you to override global 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. +// Ignore is for tests you want to ignore. It will still execute the test, but ignore the results. You should add a comment on why you ignore the test +// ForcePass is for tests you want to pass unconditionally. Test will be executed, and pass even when the test fails. You should add a comment on why you force pass the test +// ForceFail is for tests you want to fail unconditionally. Test will be executed, and fail even when the test passes. You should add a comment on why you force fail the test +type FTWTestOverride struct { + Input map[string]string `koanf:"input"` + Ignore map[string]string `koanf:"ignore"` + ForcePass map[string]string `koanf:"forcepass"` + ForceFail map[string]string `koanf:"forcefail"` + Mode string `koanf:"mode"` +} diff --git a/http/client.go b/http/client.go index 1e0f7ef..0aca2e0 100644 --- a/http/client.go +++ b/http/client.go @@ -38,7 +38,7 @@ func (c *Client) NewConnection(d Destination) error { // strings.HasSuffix(err.String(), "connection refused") { if strings.ToLower(d.Protocol) == "https" { // Commenting InsecureSkipVerify: true. - netConn, err = tls.DialWithDialer(&net.Dialer{Timeout: c.Timeout}, "tcp", hostPort, &tls.Config{}) + netConn, err = tls.DialWithDialer(&net.Dialer{Timeout: c.Timeout}, "tcp", hostPort, &tls.Config{MinVersion: tls.VersionTLS12}) } else { netConn, err = net.DialTimeout("tcp", hostPort, c.Timeout) } diff --git a/http/request.go b/http/request.go index 4463531..f9824e1 100644 --- a/http/request.go +++ b/http/request.go @@ -126,13 +126,16 @@ func buildRequest(r *Request) ([]byte, error) { return nil, err } - log.Trace().Msgf("ftw/http: this is data: %q, of len %d", r.data, len(r.data)) - // We need to add the remaining headers, unless "NoDefaults" if utils.IsNotEmpty(r.data) && r.WithAutoCompleteHeaders() { // If there is no Content-Type, then we add one r.AddHeader(ContentTypeHeader, "application/x-www-form-urlencoded") - err := r.SetData(encodeDataParameters(r.headers, r.data)) + data, err = encodeDataParameters(r.headers, r.data) + if err != nil { + log.Info().Msgf("ftw/http: cannot encode data to: %q", r.data) + return nil, err + } + err = r.SetData(data) if err != nil { log.Info().Msgf("ftw/http: cannot set data to: %q", r.data) return nil, err @@ -150,7 +153,6 @@ func buildRequest(r *Request) ([]byte, error) { } if r.WithAutoCompleteHeaders() { - log.Trace().Msgf("ftw/http: adding standard headers") r.AddStandardHeaders(len(r.data)) } @@ -184,7 +186,8 @@ func buildRequest(r *Request) ([]byte, error) { // If the values are empty in the map, then don't encode anythin // This keeps the compatibility with the python implementation func emptyQueryValues(values url.Values) bool { - for _, val := range values { + for _, v := range values { + val := v if len(val) > 1 { return false } @@ -193,29 +196,25 @@ func emptyQueryValues(values url.Values) bool { } // encodeDataParameters url encode parameters in data -func encodeDataParameters(h Header, data []byte) []byte { +func encodeDataParameters(h Header, data []byte) ([]byte, error) { + var err error + if h.Get(ContentTypeHeader) == "application/x-www-form-urlencoded" { if escapedData, _ := url.QueryUnescape(string(data)); escapedData == string(data) { - log.Trace().Msgf("ftw/http: parsing data: %q", data) queryString, err := url.ParseQuery(string(data)) if err != nil || emptyQueryValues(queryString) { - log.Trace().Msgf("ftw/http: cannot parse or empty values in query string: %s", data) - } else { - log.Trace().Msgf("ftw/http: this is the query string parsed: %+v", queryString) - encodedData := queryString.Encode() - log.Trace().Msgf("ftw/http: encoded data to: %s", encodedData) - if encodedData != string(data) { - // we need to encode data - return []byte(encodedData) - } + return data, err + } + encodedData := queryString.Encode() + if encodedData != string(data) { + // we need to encode data + return []byte(encodedData), nil } } } - return data + return data, err } func dumpRawData(b *bytes.Buffer, raw []byte) { - log.Trace().Msgf("ftw/http: using RAW data") - fmt.Fprintf(b, "%s", raw) } diff --git a/runner/run.go b/runner/run.go index 15a5b46..83f759f 100644 --- a/runner/run.go +++ b/runner/run.go @@ -34,7 +34,6 @@ func Run(include string, exclude string, showTime bool, output bool, ftwtests [] for _, t := range tests.Tests { // if we received a particular testid, skip until we find it if needToSkipTest(include, exclude, t.TestTitle, tests.Meta.Enabled) { - log.Trace().Msgf("ftw/run: skipping test %s", t.TestTitle) addResultToStats(Skipped, t.TestTitle, &stats) continue } @@ -49,7 +48,8 @@ func Run(include string, exclude string, showTime bool, output bool, ftwtests [] // Iterate over stages for _, stage := range t.Stages { // Apply global overrides initially - testRequest, err := applyInputOverride(stage.Stage.Input) + testRequest := stage.Stage.Input + err := applyInputOverride(&testRequest) if err != nil { log.Debug().Msgf("ftw/run: problem overriding input: %s", err.Error()) } @@ -60,6 +60,15 @@ func Run(include string, exclude string, showTime bool, output bool, ftwtests [] log.Fatal().Msgf("ftw/run: bad test: choose between data, encoded_request, or raw_request") } + // Create a new check + ftwcheck := check.NewCheck(config.FTWConfig) + + // Do not even run test if result is overriden. Just use the override. + if overriden := overridenTestResult(ftwcheck, t.TestTitle); overriden != Failed { + addResultToStats(overriden, t.TestTitle, &stats) + continue + } + var req *http.Request // Destination is needed for an request @@ -73,8 +82,6 @@ func Run(include string, exclude string, showTime bool, output bool, ftwtests [] if err != nil && !expectedOutput.ExpectError { log.Fatal().Msgf("ftw/run: can't connect to destination %+v - unexpected error found. Is your waf running?", dest) - addResultToStats(Skipped, t.TestTitle, &stats) - continue } req = getRequestFromTest(testRequest) @@ -85,15 +92,13 @@ func Run(include string, exclude string, showTime bool, output bool, ftwtests [] client.StopTrackingTime() - // Create a new check - ftwcheck := check.NewCheck(config.FTWConfig) ftwcheck.SetRoundTripTime(client.GetRoundTripTime().StartTime(), client.GetRoundTripTime().StopTime()) // Set expected test output in check ftwcheck.SetExpectTestOutput(&expectedOutput) // now get the test result based on output - testResult = checkResult(ftwcheck, t.TestTitle, response, err) + testResult = checkResult(ftwcheck, response, err) duration = client.GetRoundTripTime().RoundTripDuration() @@ -113,14 +118,11 @@ func Run(include string, exclude string, showTime bool, output bool, ftwtests [] func needToSkipTest(include string, exclude string, title string, skip bool) bool { result := false - - log.Trace().Msgf("ftw/run: need to include \"%s\", and to exclude \"%s\". Test title \"%s\" and skip is %t", include, exclude, title, skip) - // if we need to exclude tests, and the title matches, // it needs to be skipped if exclude != "" { - if ok, _ := regexp.MatchString(exclude, title); ok { - log.Trace().Msgf("ftw/run: %s matches %s, so exclude is true", title, exclude) + ok, err := regexp.MatchString(exclude, title) + if ok && err == nil { result = true } } @@ -128,29 +130,20 @@ func needToSkipTest(include string, exclude string, title string, skip bool) boo // if we need to include tests, but the title does not match // it needs to be skipped if include != "" { - log.Trace().Msgf("ftw/run: include is %s", include) - if ok, _ := regexp.MatchString(include, title); !ok { - log.Trace().Msgf("ftw/run: include false") + ok, err := regexp.MatchString(include, title) + if !ok && err == nil { result = true - } else { - result = false } } // if the test itself is disabled, needs to be skipped if !skip { - log.Trace().Msgf("ftw/run: test not enabled") result = true } - - log.Trace().Msgf("ftw/run: need to exclude? %t", result) - return result } func checkTestSanity(testRequest test.Input) bool { - log.Trace().Msgf("ftw/run: checking test sanity") - return (utils.IsNotEmpty(testRequest.Data) && testRequest.EncodedRequest != "") || (utils.IsNotEmpty(testRequest.Data) && testRequest.RAWRequest != "") || (testRequest.EncodedRequest != "" && testRequest.RAWRequest != "") @@ -169,54 +162,56 @@ func displayResult(quiet bool, result TestResult, duration time.Duration) { } } -// checkResult has the logic for verifying the result for the test sent -func checkResult(c *check.FTWCheck, id string, response *http.Response, responseError error) TestResult { - var result TestResult - - // Set to failed initially - result = Failed - +func overridenTestResult(c *check.FTWCheck, id string) TestResult { if c.ForcedIgnore(id) { - result = Ignored + return Ignored } if c.ForcedFail(id) { - result = ForceFail + return ForceFail } if c.ForcedPass(id) { - result = ForcePass + return ForcePass } + return Failed +} + +// checkResult has the logic for verifying the result for the test sent +func checkResult(c *check.FTWCheck, response *http.Response, responseError error) TestResult { // Request might return an error, but it could be expected, we check that first if responseError != nil && c.AssertExpectError(responseError) { - log.Trace().Msgf("ftw/check: found expected error") - result = Success + return Success } // If there was no error, perform the remaining checks - if responseError == nil { - // If we didn't expect an error, check the actual response from the waf - if c.AssertStatus(response.Parsed.StatusCode) { - log.Debug().Msgf("ftw/check: found expected response with status %d", response.Parsed.StatusCode) - result = Success - } - // Check response - if c.AssertResponseContains(response.GetBodyAsString()) { - log.Debug().Msgf("ftw/check: found response content has \"%s\"", response.GetBodyAsString()) - result = Success - } - // Lastly, check logs - if c.AssertLogContains() { - result = Success - } - // We assume that the they were already setup, for comparing - if c.AssertNoLogContains() { - result = Success - } + if responseError != nil { + return Failed + } + if c.CloudMode() { + // Cloud mode assumes that we cannot read logs. So we rely entirely on status code + c.SetCloudMode() } - return result + // If we didn't expect an error, check the actual response from the waf + if c.AssertStatus(response.Parsed.StatusCode) { + return Success + } + // Check response + if c.AssertResponseContains(response.GetBodyAsString()) { + return Success + } + // Lastly, check logs + if c.AssertLogContains() { + return Success + } + // We assume that the they were already setup, for comparing + if c.AssertNoLogContains() { + return Success + } + + return Failed } func getRequestFromTest(testRequest test.Input) *http.Request { @@ -254,23 +249,27 @@ func printUnlessQuietMode(quiet bool, format string, a ...interface{}) { } // applyInputOverride will check if config had global overrides and write that into the test. -func applyInputOverride(testRequest test.Input) (test.Input, error) { +func applyInputOverride(testRequest *test.Input) error { var retErr error overrides := config.FTWConfig.TestOverride.Input - for setting, value := range overrides { - switch setting { + for s, v := range overrides { + value := v + switch s { case "port": port, err := strconv.Atoi(value) if err != nil { retErr = errors.New("ftw/run: error getting overriden port") } - testRequest.Port = &port + *testRequest.Port = port case "dest_addr": - newValue := value - testRequest.DestAddr = &newValue + oDestAddr := &value + testRequest.DestAddr = oDestAddr + case "protocol": + oProtocol := &value + testRequest.Protocol = oProtocol default: retErr = errors.New("ftw/run: override setting not implemented yet") } } - return testRequest, retErr + return retErr } diff --git a/runner/run_test.go b/runner/run_test.go index 4bd0e25..90c4ff7 100644 --- a/runner/run_test.go +++ b/runner/run_test.go @@ -37,6 +37,7 @@ testoverride: input: dest_addr: 'httpbin.org' port: '80' + protocol: 'http' ` var yamlBrokenConfigOverride = ` @@ -48,9 +49,17 @@ logtype: timeformat: 'ddd MMM DD HH:mm:ss.S YYYY' testoverride: input: + dest_addr: 'httpbin.org' + port: '80' this_does_not_exist: 'test' ` +var yamlCloudConfig = ` +--- +testoverride: + mode: cloud +` + var logText = ` [Tue Jan 05 02:21:09.637165 2021] [:error] [pid 76:tid 139683434571520] [client 172.23.0.1:58998] [client 172.23.0.1] ModSecurity: Warning. Pattern match "\\\\b(?:keep-alive|close),\\\\s?(?:keep-alive|close)\\\\b" at REQUEST_HEADERS:Connection. [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-920-PROTOCOL-ENFORCEMENT.conf"] [line "339"] [id "920210"] [msg "Multiple/Conflicting Connection Header Data Found"] [data "close,close"] [severity "WARNING"] [ver "OWASP_CRS/3.3.0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-protocol"] [tag "paranoia-level/1"] [tag "OWASP_CRS"] [tag "capec/1000/210/272"] [hostname "localhost"] [uri "/"] [unique_id "X-PNFSe1VwjCgYRI9FsbHgAAAIY"] [Tue Jan 05 02:21:09.637731 2021] [:error] [pid 76:tid 139683434571520] [client 172.23.0.1:58998] [client 172.23.0.1] ModSecurity: Warning. Match of "pm AppleWebKit Android" against "REQUEST_HEADERS:User-Agent" required. [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-920-PROTOCOL-ENFORCEMENT.conf"] [line "1230"] [id "920300"] [msg "Request Missing an Accept Header"] [severity "NOTICE"] [ver "OWASP_CRS/3.3.0"] [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] [tag "attack-protocol"] [tag "OWASP_CRS"] [tag "capec/1000/210/272"] [tag "PCI/6.5.10"] [tag "paranoia-level/2"] [hostname "localhost"] [uri "/"] [unique_id "X-PNFSe1VwjCgYRI9FsbHgAAAIY"] @@ -106,7 +115,7 @@ tests: output: response_contains: "Hello, client" - test_title: "101" - description: this tests exceptions + description: "this tests exceptions" stages: - stage: input: @@ -191,36 +200,59 @@ meta: description: "Example Test" tests: - test_title: "200" - stages: - - stage: - input: - dest_addr: "httpbin.org" - port: 80 - headers: - User-Agent: "ModSecurity CRS 3 Tests" - Accept: "*/*" - Host: "localhost" - output: - log_contains: id \"949110\" + stages: + - stage: + input: + dest_addr: TEST_ADDR + port: TEST_PORT + headers: + User-Agent: "ModSecurity CRS 3 Tests" + Accept: "*/*" + Host: "localhost" + output: + log_contains: id \"949110\" - test_title: "201" - stages: - - stage: - input: - dest_addr: "httpbin.org" - port: 80 - headers: - User-Agent: "ModSecurity CRS 3 Tests" - Accept: "*/*" - Host: "localhost" - output: - no_log_contains: ABCDE + stages: + - stage: + input: + dest_addr: TEST_ADDR + port: TEST_PORT + headers: + User-Agent: "ModSecurity CRS 3 Tests" + Accept: "*/*" + Host: "localhost" + output: + no_log_contains: ABCDE +` + +var yamlFailedTest = `--- +meta: + author: "tester" + enabled: true + name: "gotest-ftw.yaml" + description: "Example Test" +tests: + - test_title: "990" + description: test that fails + stages: + - stage: + input: + dest_addr: TEST_ADDR + port: TEST_PORT + headers: + User-Agent: "ModSecurity CRS 3 Tests" + Accept: "*/*" + Host: "none.host" + output: + status: [413] ` // Error checking omitted for brevity -func testServer() (server *httptest.Server) { +func newTestServer() *httptest.Server { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "Hello, client") + w.WriteHeader(200) + _, _ = w.Write([]byte("Hello, client")) })) return ts @@ -239,15 +271,15 @@ func replaceLocalhostWithTestServer(yaml string, url string) string { func TestRun(t *testing.T) { // This is an integration test, and depends on having the waf up for checking logs // We might use it to check for error, so we don't need anything up and running - config.ImportFromString(yamlConfig) + err := config.NewConfigFromString(yamlConfig) + if err != nil { + t.Errorf("Failed!") + } logName, _ := utils.CreateTempFileWithContent(logText, "test-apache-*.log") config.FTWConfig.LogFile = logName // setup test webserver (not a waf) - server := testServer() - - // We should inject server.URL now into some tests - // d := DestinationFromString(server.URL) + server := newTestServer() yamlTestContent := replaceLocalhostWithTestServer(yamlTest, server.URL) filename, err := utils.CreateTempFileWithContent(yamlTestContent, "goftw-test-*.yaml") @@ -260,8 +292,8 @@ func TestRun(t *testing.T) { tests, _ := test.GetTestsFromFiles(filename) t.Run("showtime and execute all", func(t *testing.T) { - if res := Run("", "", false, true, tests); res > 0 { - t.Error("Oops, test run failed!") + if res := Run("", "", true, false, tests); res > 0 { + t.Errorf("Oops, %d tests failed to run!", res) } }) @@ -304,7 +336,10 @@ func TestRun(t *testing.T) { func TestOverrideRun(t *testing.T) { // This is an integration test, and depends on having the waf up for checking logs // We might use it to check for error, so we don't need anything up and running - config.ImportFromString(yamlConfigOverride) + err := config.NewConfigFromString(yamlConfigOverride) + if err != nil { + t.Errorf("Failed!") + } logName, _ := utils.CreateTempFileWithContent(logText, "test-apache-*.log") config.FTWConfig.LogFile = logName @@ -317,17 +352,24 @@ func TestOverrideRun(t *testing.T) { tests, _ := test.GetTestsFromFiles(filename) - t.Run("showtime and execute all", func(t *testing.T) { + t.Run("override and execute all", func(t *testing.T) { if res := Run("", "", false, true, tests); res > 0 { t.Error("Oops, test run failed!") } }) + + // Clean up + os.Remove(logName) + os.Remove(filename) } func TestBrokenOverrideRun(t *testing.T) { // This is an integration test, and depends on having the waf up for checking logs // We might use it to check for error, so we don't need anything up and running - config.ImportFromString(yamlBrokenConfigOverride) + err := config.NewConfigFromString(yamlBrokenConfigOverride) + if err != nil { + t.Errorf("Failed!") + } logName, _ := utils.CreateTempFileWithContent(logText, "test-apache-*.log") config.FTWConfig.LogFile = logName @@ -345,12 +387,19 @@ func TestBrokenOverrideRun(t *testing.T) { t.Error("Oops, test run failed!") } }) + + // Clean up + os.Remove(logName) + os.Remove(filename) } func TestDisabledRun(t *testing.T) { // This is an integration test, and depends on having the waf up for checking logs // We might use it to check for error, so we don't need anything up and running - config.ImportFromString(yamlConfig) + err := config.NewConfigFromString(yamlConfig) + if err != nil { + t.Errorf("Failed!") + } logName, _ := utils.CreateTempFileWithContent(logText, "test-apache-*.log") config.FTWConfig.LogFile = logName @@ -364,16 +413,23 @@ func TestDisabledRun(t *testing.T) { tests, _ := test.GetTestsFromFiles(filename) t.Run("showtime and execute all", func(t *testing.T) { - if res := Run("", "", false, true, tests); res > 0 { + if res := Run("*", "", false, true, tests); res > 0 { t.Error("Oops, test run failed!") } }) + + // Clean up + os.Remove(logName) + os.Remove(filename) } func TestLogsRun(t *testing.T) { // This is an integration test, and depends on having the waf up for checking logs // We might use it to check for error, so we don't need anything up and running - config.ImportFromString(yamlConfig) + err := config.NewConfigFromString(yamlConfig) + if err != nil { + t.Errorf("Failed!") + } logName, _ := utils.CreateTempFileWithContent(logText, "test-apache-*.log") config.FTWConfig.LogFile = logName @@ -391,4 +447,73 @@ func TestLogsRun(t *testing.T) { t.Error("Oops, test run failed!") } }) + + // Clean up + os.Remove(logName) + os.Remove(filename) +} + +func TestCloudRun(t *testing.T) { + // This is an integration test, and depends on having the waf up for checking logs + // We might use it to check for error, so we don't need anything up and running + err := config.NewConfigFromString(yamlCloudConfig) + if err != nil { + t.Errorf("Failed!") + } + + filename, err := utils.CreateTempFileWithContent(yamlTestLogs, "goftw-test-*.yaml") + if err != nil { + t.Fatalf("Failed!: %s\n", err.Error()) + } else { + fmt.Printf("Using testfile %s\n", filename) + } + + tests, _ := test.GetTestsFromFiles(filename) + + t.Run("showtime and execute all", func(t *testing.T) { + if res := Run("", "", false, true, tests); res > 0 { + t.Error("Oops, test run failed!") + } + }) + + // Clean up + os.Remove(filename) +} + +func TestFailedTestsRun(t *testing.T) { + // This is an integration test, and depends on having the waf up for checking logs + // We might use it to check for error, so we don't need anything up and running + err := config.NewConfigFromString(yamlConfig) + if err != nil { + t.Errorf("Failed!") + } + logName, _ := utils.CreateTempFileWithContent(logText, "test-apache-*.log") + config.FTWConfig.LogFile = logName + + // setup test webserver (not a waf) + server := newTestServer() + yamlTestContent := replaceLocalhostWithTestServer(yamlFailedTest, server.URL) + + filename, err := utils.CreateTempFileWithContent(yamlTestContent, "goftw-test-*.yaml") + if err != nil { + t.Fatalf("Failed!: %s\n", err.Error()) + } else { + fmt.Printf("Using testfile %s\n", filename) + } + + tests, err := test.GetTestsFromFiles(filename) + if err != nil { + t.Error(err.Error()) + } + + t.Run("run test that fails", func(t *testing.T) { + if res := Run("*", "", false, false, tests); res != 1 { + t.Error("Oops, test run failed!") + } + }) + + // Clean up + server.Close() + os.Remove(logName) + os.Remove(filename) } diff --git a/runner/stats.go b/runner/stats.go index 3d9abab..2977d04 100644 --- a/runner/stats.go +++ b/runner/stats.go @@ -10,7 +10,7 @@ import ( // TestResult type are the values that the result of a test can have type TestResult int -// Handy contants for test results +// Handy constants for test results const ( Success TestResult = iota Failed diff --git a/test/files.go b/test/files.go index 6286d52..60cc2de 100644 --- a/test/files.go +++ b/test/files.go @@ -28,7 +28,6 @@ func GetTestsFromFiles(globPattern string) ([]FTWTest, error) { log.Info().Msgf(yaml.FormatError(err, true, true)) return tests, err } - log.Trace().Msgf("ftw/test: reading file %s", test) tests = append(tests, t) } diff --git a/test/common.go b/test/types.go similarity index 100% rename from test/common.go rename to test/types.go diff --git a/waflog/read.go b/waflog/read.go index 5403358..3053bcd 100644 --- a/waflog/read.go +++ b/waflog/read.go @@ -13,8 +13,6 @@ import ( // Contains looks in logfile for regex func (ll *FTWLogLines) Contains(match string) bool { - log.Trace().Msgf("ftw/waflog: truncating at %s", ll.TimeTruncate) - log.Trace().Msgf("ftw/waflog: Looking at file %s, between %s and %s, truncating on %s", ll.FileName, ll.Since, ll.Until, ll.TimeTruncate.String()) // this should be a flag lines := ll.getLinesSinceUntil() // if we need to truncate file @@ -40,23 +38,16 @@ func (ll *FTWLogLines) Contains(match string) bool { } func isBetweenOrEqual(dt gostradamus.DateTime, start gostradamus.DateTime, end gostradamus.DateTime, duration time.Duration) bool { - log.Trace().Msgf("ftw/waflog: truncating to %s", duration) // First check if we need to truncate times dtTime := dt.Time().Truncate(duration) startTime := start.Time().Truncate(duration) endTime := end.Time().Truncate(duration) isBetween := dtTime.After(startTime) && dtTime.Before(endTime) - log.Trace().Msgf("ftw/waflog: time %s is between %s and %s? %t", dtTime, - startTime, endTime, isBetween) isEqualStart := dtTime.Equal(startTime) - log.Trace().Msgf("ftw/waflog: time %s is equal to %s ? %t", dtTime, - startTime, isEqualStart) isEqualEnd := dtTime.Equal(endTime) - log.Trace().Msgf("ftw/waflog: time %s is equal to %s ? %t", dtTime, - startTime, isEqualEnd) return isBetween || isEqualStart || isEqualEnd } @@ -88,16 +79,13 @@ func (ll *FTWLogLines) getLinesSinceUntil() [][]byte { for { line, _, err := scanner.LineBytes() if err != nil { - if err == io.EOF { - log.Trace().Msgf("got to the beginning of file") - } else { + if err != io.EOF { log.Trace().Err(err) } break } if matchedLine := compiledRegex.FindSubmatch(line); matchedLine != nil { date := matchedLine[1] - log.Trace().Msgf("ftw/waflog: matched %s in line %s", date, matchedLine) // well, go doesn't want to have a proper time format, so we need to use gostradamus t, err := gostradamus.ParseInTimezone(string(date), ll.TimeFormat, tzone) if err != nil { @@ -116,10 +104,6 @@ func (ll *FTWLogLines) getLinesSinceUntil() [][]byte { copy(saneCopy, line) found = append(found, saneCopy) continue - } else { - log.Trace().Msgf("ftw/waflog: time %s is not between %s and %s", t.Time(), - since.Format(ll.TimeFormat), - until.Format(ll.TimeFormat)) } // if we are before since, we need to stop searching if dt.IsBetween(gostradamus.DateTimeFromTime(time.Time{}).InTimezone(gostradamus.Local()), diff --git a/waflog/waflog_test.go b/waflog/read_test.go similarity index 100% rename from waflog/waflog_test.go rename to waflog/read_test.go diff --git a/waflog/base.go b/waflog/types.go similarity index 100% rename from waflog/base.go rename to waflog/types.go