diff --git a/v2/Makefile b/v2/Makefile index a24c1194fa..cfcd3847fa 100644 --- a/v2/Makefile +++ b/v2/Makefile @@ -18,5 +18,9 @@ docs: ./cmd/docgen/docgen docs.md nuclei-jsonschema.json test: $(GOTEST) -v ./... +integration: + bash ../integration_tests/run.sh +functional: + bash cmd/functional-tests/run.sh tidy: $(GOMOD) tidy \ No newline at end of file diff --git a/v2/pkg/operators/operators.go b/v2/pkg/operators/operators.go index 0b9087312a..804965d69a 100644 --- a/v2/pkg/operators/operators.go +++ b/v2/pkg/operators/operators.go @@ -72,11 +72,64 @@ type Result struct { // OutputExtracts is the list of extracts to be displayed on screen. OutputExtracts []string // DynamicValues contains any dynamic values to be templated - DynamicValues map[string]interface{} + DynamicValues map[string][]string // PayloadValues contains payload values provided by user. (Optional) PayloadValues map[string]interface{} } +// MakeDynamicValuesCallback takes an input dynamic values map and calls +// the callback function with all variations of the data in input in form +// of map[string]string (interface{}). +func MakeDynamicValuesCallback(input map[string][]string, iterateAllValues bool, callback func(map[string]interface{}) bool) { + output := make(map[string]interface{}, len(input)) + + if !iterateAllValues { + for k, v := range input { + if len(v) > 0 { + output[k] = v[0] + } + } + callback(output) + return + } + inputIndex := make(map[string]int, len(input)) + + var maxValue int + for _, v := range input { + if len(v) > maxValue { + maxValue = len(v) + } + } + + for i := 0; i < maxValue; i++ { + for k, v := range input { + if len(v) == 0 { + continue + } + if len(v) == 1 { + output[k] = v[0] + continue + } + if gotIndex, ok := inputIndex[k]; !ok { + inputIndex[k] = 0 + output[k] = v[0] + } else { + newIndex := gotIndex + 1 + if newIndex >= len(v) { + output[k] = v[len(v)-1] + continue + } + output[k] = v[newIndex] + inputIndex[k] = newIndex + } + } + // skip if the callback says so + if callback(output) { + return + } + } +} + // Merge merges a result structure into the other. func (r *Result) Merge(result *Result) { if !r.Matched && result.Matched { @@ -115,7 +168,7 @@ func (operators *Operators) Execute(data map[string]interface{}, match MatchFunc result := &Result{ Matches: make(map[string][]string), Extracts: make(map[string][]string), - DynamicValues: make(map[string]interface{}), + DynamicValues: make(map[string][]string), } // Start with the extractors first and evaluate them. @@ -126,8 +179,10 @@ func (operators *Operators) Execute(data map[string]interface{}, match MatchFunc extractorResults = append(extractorResults, match) if extractor.Internal { - if _, ok := result.DynamicValues[extractor.Name]; !ok { - result.DynamicValues[extractor.Name] = match + if data, ok := result.DynamicValues[extractor.Name]; !ok { + result.DynamicValues[extractor.Name] = []string{match} + } else { + result.DynamicValues[extractor.Name] = append(data, match) } } else { result.OutputExtracts = append(result.OutputExtracts, match) diff --git a/v2/pkg/operators/operators_test.go b/v2/pkg/operators/operators_test.go new file mode 100644 index 0000000000..204cd57bab --- /dev/null +++ b/v2/pkg/operators/operators_test.go @@ -0,0 +1,57 @@ +package operators + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMakeDynamicValuesCallback(t *testing.T) { + input := map[string][]string{ + "a": []string{"1", "2"}, + "b": []string{"3"}, + "c": []string{}, + "d": []string{"A", "B", "C"}, + } + + count := 0 + MakeDynamicValuesCallback(input, true, func(data map[string]interface{}) bool { + count++ + require.Len(t, data, 3, "could not get correct output length") + return false + }) + require.Equal(t, 3, count, "could not get correct result count") + + t.Run("all", func(t *testing.T) { + input := map[string][]string{ + "a": []string{"1"}, + "b": []string{"2"}, + "c": []string{"3"}, + } + + count := 0 + MakeDynamicValuesCallback(input, true, func(data map[string]interface{}) bool { + count++ + require.Len(t, data, 3, "could not get correct output length") + return false + }) + require.Equal(t, 1, count, "could not get correct result count") + }) + + t.Run("first", func(t *testing.T) { + input := map[string][]string{ + "a": []string{"1", "2"}, + "b": []string{"3"}, + "c": []string{}, + "d": []string{"A", "B", "C"}, + } + + count := 0 + MakeDynamicValuesCallback(input, false, func(data map[string]interface{}) bool { + count++ + require.Len(t, data, 3, "could not get correct output length") + return false + }) + require.Equal(t, 1, count, "could not get correct result count") + }) +} diff --git a/v2/pkg/protocols/common/generators/maps.go b/v2/pkg/protocols/common/generators/maps.go index 31df768bb3..d88ec3dbde 100644 --- a/v2/pkg/protocols/common/generators/maps.go +++ b/v2/pkg/protocols/common/generators/maps.go @@ -1,9 +1,49 @@ package generators import ( + "reflect" "strings" ) +// MergeMapsMany merges many maps into a new map +func MergeMapsMany(maps ...interface{}) map[string][]string { + m := make(map[string][]string) + for _, gotMap := range maps { + val := reflect.ValueOf(gotMap) + if val.Kind() != reflect.Map { + continue + } + appendToSlice := func(key, value string) { + if values, ok := m[key]; !ok { + m[key] = []string{value} + } else { + m[key] = append(values, value) + } + } + for _, e := range val.MapKeys() { + v := val.MapIndex(e) + switch v.Kind() { + case reflect.Slice, reflect.Array: + for i := 0; i < v.Len(); i++ { + appendToSlice(e.String(), v.Index(i).String()) + } + case reflect.String: + appendToSlice(e.String(), v.String()) + case reflect.Interface: + switch data := v.Interface().(type) { + case string: + appendToSlice(e.String(), data) + case []string: + for _, value := range data { + appendToSlice(e.String(), value) + } + } + } + } + } + return m +} + // MergeMaps merges two maps into a new map func MergeMaps(m1, m2 map[string]interface{}) map[string]interface{} { m := make(map[string]interface{}, len(m1)+len(m2)) diff --git a/v2/pkg/protocols/common/generators/maps_test.go b/v2/pkg/protocols/common/generators/maps_test.go new file mode 100644 index 0000000000..870af84c9f --- /dev/null +++ b/v2/pkg/protocols/common/generators/maps_test.go @@ -0,0 +1,16 @@ +package generators + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMergeMapsMany(t *testing.T) { + got := MergeMapsMany(map[string]interface{}{"a": []string{"1", "2"}, "c": "5"}, map[string][]string{"b": []string{"3", "4"}}) + require.Equal(t, map[string][]string{ + "a": []string{"1", "2"}, + "b": []string{"3", "4"}, + "c": []string{"5"}, + }, got, "could not get correct merged map") +} diff --git a/v2/pkg/protocols/http/build_request.go b/v2/pkg/protocols/http/build_request.go index e72363a7c5..59fa9e7039 100644 --- a/v2/pkg/protocols/http/build_request.go +++ b/v2/pkg/protocols/http/build_request.go @@ -53,14 +53,9 @@ func (g *generatedRequest) URL() string { // Make creates a http request for the provided input. // It returns io.EOF as error when all the requests have been exhausted. -func (r *requestGenerator) Make(baseURL string, dynamicValues map[string]interface{}) (*generatedRequest, error) { +func (r *requestGenerator) Make(baseURL, data string, payloads, dynamicValues map[string]interface{}) (*generatedRequest, error) { if r.request.SelfContained { - return r.makeSelfContainedRequest(dynamicValues) - } - // We get the next payload for the request. - data, payloads, ok := r.nextValue() - if !ok { - return nil, io.EOF + return r.makeSelfContainedRequest(data, payloads, dynamicValues) } ctx := context.Background() @@ -107,12 +102,7 @@ func (r *requestGenerator) Make(baseURL string, dynamicValues map[string]interfa return r.makeHTTPRequestFromModel(ctx, data, values, payloads) } -func (r *requestGenerator) makeSelfContainedRequest(dynamicValues map[string]interface{}) (*generatedRequest, error) { - // We get the next payload for the request. - data, payloads, ok := r.nextValue() - if !ok { - return nil, io.EOF - } +func (r *requestGenerator) makeSelfContainedRequest(data string, payloads, dynamicValues map[string]interface{}) (*generatedRequest, error) { ctx := context.Background() isRawRequest := r.request.isRaw() diff --git a/v2/pkg/protocols/http/build_request_test.go b/v2/pkg/protocols/http/build_request_test.go index 7cca368a6f..8b8e3f6127 100644 --- a/v2/pkg/protocols/http/build_request_test.go +++ b/v2/pkg/protocols/http/build_request_test.go @@ -87,7 +87,8 @@ func TestMakeRequestFromModal(t *testing.T) { require.Nil(t, err, "could not compile http request") generator := request.newGenerator() - req, err := generator.Make("https://example.com", map[string]interface{}{}) + inputData, payloads, _ := generator.nextValue() + req, err := generator.Make("https://example.com", inputData, payloads, map[string]interface{}{}) require.Nil(t, err, "could not make http request") bodyBytes, _ := req.request.BodyBytes() @@ -114,12 +115,14 @@ func TestMakeRequestFromModalTrimSuffixSlash(t *testing.T) { require.Nil(t, err, "could not compile http request") generator := request.newGenerator() - req, err := generator.Make("https://example.com/test.php", map[string]interface{}{}) + inputData, payloads, _ := generator.nextValue() + req, err := generator.Make("https://example.com/test.php", inputData, payloads, map[string]interface{}{}) require.Nil(t, err, "could not make http request") require.Equal(t, "https://example.com/test.php?query=example", req.request.URL.String(), "could not get correct request path") generator = request.newGenerator() - req, err = generator.Make("https://example.com/test/", map[string]interface{}{}) + inputData, payloads, _ = generator.nextValue() + req, err = generator.Make("https://example.com/test/", inputData, payloads, map[string]interface{}{}) require.Nil(t, err, "could not make http request") require.Equal(t, "https://example.com/test/?query=example", req.request.URL.String(), "could not get correct request path") } @@ -152,12 +155,14 @@ Accept-Encoding: gzip`}, require.Nil(t, err, "could not compile http request") generator := request.newGenerator() - req, err := generator.Make("https://example.com", map[string]interface{}{}) + inputData, payloads, _ := generator.nextValue() + req, err := generator.Make("https://example.com", inputData, payloads, map[string]interface{}{}) require.Nil(t, err, "could not make http request") authorization := req.request.Header.Get("Authorization") require.Equal(t, "Basic admin:admin", authorization, "could not get correct authorization headers from raw") - req, err = generator.Make("https://example.com", map[string]interface{}{}) + inputData, payloads, _ = generator.nextValue() + req, err = generator.Make("https://example.com", inputData, payloads, map[string]interface{}{}) require.Nil(t, err, "could not make http request") authorization = req.request.Header.Get("Authorization") require.Equal(t, "Basic admin:guest", authorization, "could not get correct authorization headers from raw") @@ -191,12 +196,14 @@ Accept-Encoding: gzip`}, require.Nil(t, err, "could not compile http request") generator := request.newGenerator() - req, err := generator.Make("https://example.com", map[string]interface{}{}) + inputData, payloads, _ := generator.nextValue() + req, err := generator.Make("https://example.com", inputData, payloads, map[string]interface{}{}) require.Nil(t, err, "could not make http request") authorization := req.request.Header.Get("Authorization") require.Equal(t, "Basic YWRtaW46YWRtaW4=", authorization, "could not get correct authorization headers from raw") - req, err = generator.Make("https://example.com", map[string]interface{}{}) + inputData, payloads, _ = generator.nextValue() + req, err = generator.Make("https://example.com", inputData, payloads, map[string]interface{}{}) require.Nil(t, err, "could not make http request") authorization = req.request.Header.Get("Authorization") require.Equal(t, "Basic YWRtaW46Z3Vlc3Q=", authorization, "could not get correct authorization headers from raw") @@ -232,7 +239,8 @@ func TestMakeRequestFromModelUniqueInteractsh(t *testing.T) { }) require.Nil(t, err, "could not create interactsh client") - got, err := generator.Make("https://example.com", map[string]interface{}{}) + inputData, payloads, _ := generator.nextValue() + got, err := generator.Make("https://example.com", inputData, payloads, map[string]interface{}{}) require.Nil(t, err, "could not make http request") // check if all the interactsh markers are replaced with unique urls diff --git a/v2/pkg/protocols/http/http.go b/v2/pkg/protocols/http/http.go index a96177862c..599870204e 100644 --- a/v2/pkg/protocols/http/http.go +++ b/v2/pkg/protocols/http/http.go @@ -176,6 +176,9 @@ type Request struct { // description: | // SkipVariablesCheck skips the check for unresolved variables in request SkipVariablesCheck bool `yaml:"skip-variables-check,omitempty" jsonschema:"title=skip variable checks,description=Skips the check for unresolved variables in request"` + // description: | + // IterateAll iterates all the values extracted from internal extractors + IterateAll bool `yaml:"iterate-all,omitempty" jsonschema:"title=iterate all the values,description=Iterates all the values extracted from internal extractors"` } // GetID returns the unique ID of the request if any. diff --git a/v2/pkg/protocols/http/request.go b/v2/pkg/protocols/http/request.go index 1868f85bc0..e2c3f831c1 100644 --- a/v2/pkg/protocols/http/request.go +++ b/v2/pkg/protocols/http/request.go @@ -19,6 +19,7 @@ import ( "moul.io/http2curl" "github.com/projectdiscovery/gologger" + "github.com/projectdiscovery/nuclei/v2/pkg/operators" "github.com/projectdiscovery/nuclei/v2/pkg/output" "github.com/projectdiscovery/nuclei/v2/pkg/protocols" "github.com/projectdiscovery/nuclei/v2/pkg/protocols/common/expressions" @@ -47,7 +48,12 @@ func (request *Request) executeRaceRequest(reqURL string, previous output.Intern // Requests within race condition should be dumped once and the output prefilled to allow DSL language to work // This will introduce a delay and will populate in hacky way the field "request" of outputEvent generator := request.newGenerator() - requestForDump, err := generator.Make(reqURL, nil) + + inputData, payloads, ok := generator.nextValue() + if !ok { + return nil + } + requestForDump, err := generator.Make(reqURL, inputData, payloads, nil) if err != nil { return err } @@ -65,7 +71,11 @@ func (request *Request) executeRaceRequest(reqURL string, previous output.Intern // Pre-Generate requests for i := 0; i < request.RaceNumberRequests; i++ { generator := request.newGenerator() - generatedRequest, err := generator.Make(reqURL, nil) + inputData, payloads, ok := generator.nextValue() + if !ok { + break + } + generatedRequest, err := generator.Make(reqURL, inputData, payloads, nil) if err != nil { return err } @@ -104,7 +114,11 @@ func (request *Request) executeParallelHTTP(reqURL string, dynamicValues output. var requestErr error mutex := &sync.Mutex{} for { - generatedHttpRequest, err := generator.Make(reqURL, dynamicValues) + inputData, payloads, ok := generator.nextValue() + if !ok { + break + } + generatedHttpRequest, err := generator.Make(reqURL, inputData, payloads, dynamicValues) if err != nil { if err == io.EOF { break @@ -167,11 +181,12 @@ func (request *Request) executeTurboHTTP(reqURL string, dynamicValues, previous var requestErr error mutex := &sync.Mutex{} for { - generatedHttpRequest, err := generator.Make(reqURL, dynamicValues) + inputData, payloads, ok := generator.nextValue() + if !ok { + break + } + generatedHttpRequest, err := generator.Make(reqURL, inputData, payloads, dynamicValues) if err != nil { - if err == io.EOF { - break - } request.options.Progress.IncrementFailedRequestsBy(int64(generator.Total())) return err } @@ -215,62 +230,93 @@ func (request *Request) ExecuteWithResults(reqURL string, dynamicValues, previou generator := request.newGenerator() + var gotDynamicValues map[string][]string requestCount := 1 var requestErr error for { - hasInteractMarkers := interactsh.HasMatchers(request.CompiledOperators) + // returns two values, error and skip, which skips the execution for the request instance. + executeFunc := func(data string, payloads, dynamicValue map[string]interface{}) (bool, error) { + hasInteractMarkers := interactsh.HasMatchers(request.CompiledOperators) - generatedHttpRequest, err := generator.Make(reqURL, dynamicValues) - if err != nil { - if err == io.EOF { - break + generatedHttpRequest, err := generator.Make(reqURL, data, payloads, dynamicValue) + if err != nil { + if err == io.EOF { + return true, nil + } + request.options.Progress.IncrementFailedRequestsBy(int64(generator.Total())) + return true, err } - request.options.Progress.IncrementFailedRequestsBy(int64(generator.Total())) - return err - } - if reqURL == "" { - reqURL = generatedHttpRequest.URL() - } - request.dynamicValues = generatedHttpRequest.dynamicValues - // Check if hosts keep erroring - if request.options.HostErrorsCache != nil && request.options.HostErrorsCache.Check(reqURL) { - break - } - var gotOutput bool - request.options.RateLimiter.Take() - err = request.executeRequest(reqURL, generatedHttpRequest, previous, hasInteractMarkers, func(event *output.InternalWrappedEvent) { - // Add the extracts to the dynamic values if any. - if event.OperatorsResult != nil { - gotOutput = true - dynamicValues = generators.MergeMaps(dynamicValues, event.OperatorsResult.DynamicValues) + if reqURL == "" { + reqURL = generatedHttpRequest.URL() } - if hasInteractMarkers && request.options.Interactsh != nil { - request.options.Interactsh.RequestEvent(generatedHttpRequest.interactshURLs, &interactsh.RequestData{ - MakeResultFunc: request.MakeResultEvent, - Event: event, - Operators: request.CompiledOperators, - MatchFunc: request.Match, - ExtractFunc: request.Extract, - }) - } else { - callback(event) + request.dynamicValues = generatedHttpRequest.dynamicValues + + // Check if hosts keep erroring + if request.options.HostErrorsCache != nil && request.options.HostErrorsCache.Check(reqURL) { + return true, nil } - }, requestCount) - // If a variable is unresolved, skip all further requests - if err == errStopExecution { - break - } - if err != nil { - if request.options.HostErrorsCache != nil && request.options.HostErrorsCache.CheckError(err) { - request.options.HostErrorsCache.MarkFailed(reqURL) + var gotOutput bool + request.options.RateLimiter.Take() + + err = request.executeRequest(reqURL, generatedHttpRequest, previous, hasInteractMarkers, func(event *output.InternalWrappedEvent) { + // Add the extracts to the dynamic values if any. + if event.OperatorsResult != nil { + gotOutput = true + gotDynamicValues = generators.MergeMapsMany(event.OperatorsResult.DynamicValues, dynamicValues, gotDynamicValues) + } + if hasInteractMarkers && request.options.Interactsh != nil { + request.options.Interactsh.RequestEvent(generatedHttpRequest.interactshURLs, &interactsh.RequestData{ + MakeResultFunc: request.MakeResultEvent, + Event: event, + Operators: request.CompiledOperators, + MatchFunc: request.Match, + ExtractFunc: request.Extract, + }) + } else { + callback(event) + } + }, requestCount) + + // If a variable is unresolved, skip all further requests + if err == errStopExecution { + return true, nil + } + if err != nil { + if request.options.HostErrorsCache != nil && request.options.HostErrorsCache.CheckError(err) { + request.options.HostErrorsCache.MarkFailed(reqURL) + } + requestErr = err + } + requestCount++ + request.options.Progress.IncrementRequests() + + // If this was a match and we want to stop at first match, skip all further requests. + if (generatedHttpRequest.original.options.Options.StopAtFirstMatch || request.StopAtFirstMatch) && gotOutput { + return true, nil } - requestErr = err + return false, nil } - requestCount++ - request.options.Progress.IncrementRequests() - // If this was a match, and we want to stop at first match, skip all further requests. - if (generatedHttpRequest.original.options.Options.StopAtFirstMatch || request.StopAtFirstMatch) && gotOutput { + inputData, payloads, ok := generator.nextValue() + if !ok { + break + } + var gotErr error + var skip bool + if len(gotDynamicValues) > 0 { + operators.MakeDynamicValuesCallback(gotDynamicValues, request.IterateAll, func(data map[string]interface{}) bool { + if skip, gotErr = executeFunc(inputData, payloads, data); skip || gotErr != nil { + return true + } + return false + }) + } else { + skip, gotErr = executeFunc(inputData, payloads, dynamicValues) + } + if gotErr != nil && requestErr == nil { + requestErr = gotErr + } + if skip || gotErr != nil { break } } @@ -310,7 +356,7 @@ func (request *Request) executeRequest(reqURL string, generatedRequest *generate if request.options.Options.Debug || request.options.Options.DebugRequests { gologger.Info().Msgf("[%s] Dumped HTTP request for %s\n\n", request.options.TemplateID, reqURL) gologger.Print().Msgf("%s", dumpedRequestString) - } + } } var formedURL string var hostname string diff --git a/v2/pkg/protocols/http/request_test.go b/v2/pkg/protocols/http/request_test.go new file mode 100644 index 0000000000..5535e84e1d --- /dev/null +++ b/v2/pkg/protocols/http/request_test.go @@ -0,0 +1,94 @@ +package http + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/projectdiscovery/nuclei/v2/pkg/model" + "github.com/projectdiscovery/nuclei/v2/pkg/model/types/severity" + "github.com/projectdiscovery/nuclei/v2/pkg/operators" + "github.com/projectdiscovery/nuclei/v2/pkg/operators/extractors" + "github.com/projectdiscovery/nuclei/v2/pkg/operators/matchers" + "github.com/projectdiscovery/nuclei/v2/pkg/output" + "github.com/projectdiscovery/nuclei/v2/pkg/testutils" +) + +func TestHTTPExtractMultipleReuse(t *testing.T) { + options := testutils.DefaultOptions + + testutils.Init(options) + templateID := "testing-http" + request := &Request{ + ID: templateID, + Raw: []string{ + `GET /robots.txt HTTP/1.1 + Host: {{Hostname}} + User-Agent: Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:80.0) Gecko/20100101 Firefox/80.0 + Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 + Accept-Language: en-US,en;q=0.5 + `, + + `GET {{endpoint}} HTTP/1.1 + Host: {{Hostname}} + User-Agent: Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:80.0) Gecko/20100101 Firefox/80.0 + Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 + Accept-Language: en-US,en;q=0.5 + `, + }, + Operators: operators.Operators{ + Matchers: []*matchers.Matcher{{ + Part: "body", + Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher}, + Words: []string{"match /a", "match /b", "match /c"}, + }}, + Extractors: []*extractors.Extractor{{ + Part: "body", + Name: "endpoint", + Type: extractors.TypeHolder{ExtractorType: extractors.RegexExtractor}, + Regex: []string{"(?m)/([a-zA-Z0-9-_/\\\\]+)"}, + Internal: true, + }}, + }, + IterateAll: true, + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/robots.txt": + _, _ = w.Write([]byte(`User-agent: Googlebot +Disallow: /a +Disallow: /b +Disallow: /c`)) + default: + _, _ = w.Write([]byte(fmt.Sprintf(`match %v`, r.URL.Path))) + } + })) + defer ts.Close() + + executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{ + ID: templateID, + Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"}, + }) + + err := request.Compile(executerOpts) + require.Nil(t, err, "could not compile network request") + + var finalEvent *output.InternalWrappedEvent + var matchCount int + t.Run("test", func(t *testing.T) { + metadata := make(output.InternalEvent) + previous := make(output.InternalEvent) + err := request.ExecuteWithResults(ts.URL, metadata, previous, func(event *output.InternalWrappedEvent) { + if event.OperatorsResult != nil && event.OperatorsResult.Matched { + matchCount++ + } + finalEvent = event + }) + require.Nil(t, err, "could not execute network request") + }) + require.NotNil(t, finalEvent, "could not get event output from request") + require.Equal(t, 3, matchCount, "could not get correct match count") +}