diff --git a/cmd/integration-test/matcher-status.go b/cmd/integration-test/matcher-status.go index 173df64173..70fac1bd98 100644 --- a/cmd/integration-test/matcher-status.go +++ b/cmd/integration-test/matcher-status.go @@ -2,8 +2,11 @@ package main import ( "fmt" + "net/http" + "net/http/httptest" "strings" + "github.com/julienschmidt/httprouter" "github.com/projectdiscovery/nuclei/v3/pkg/output" "github.com/projectdiscovery/nuclei/v3/pkg/testutils" "github.com/projectdiscovery/nuclei/v3/pkg/utils/json" @@ -16,6 +19,7 @@ var matcherStatusTestcases = []TestCaseInfo{ {Path: "protocols/javascript/net-https.yaml", TestCase: &javascriptNoAccess{}}, {Path: "protocols/websocket/basic.yaml", TestCase: &websocketNoAccess{}}, {Path: "protocols/dns/a.yaml", TestCase: &dnsNoAccess{}}, + {Path: "protocols/http/matcher-status-and.yaml,protocols/http/matcher-status-and-cluster.yaml", TestCase: &httpMatcherStatusAnd{}}, } type httpNoAccess struct{} @@ -118,3 +122,26 @@ func (h *dnsNoAccess) Execute(filePath string) error { } return nil } + +type httpMatcherStatusAnd struct{} + +// Execute verifies that clustered templates with matchers-condition: and +// produce failure events when -matcher-status is enabled. +func (h *httpMatcherStatusAnd) Execute(filePath string) error { + router := httprouter.New() + router.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + _, _ = w.Write([]byte("ok")) + }) + ts := httptest.NewServer(router) + defer ts.Close() + + files := strings.Split(filePath, ",") + results, err := testutils.RunNucleiTemplateAndGetResults(files[0], ts.URL, debug, "-t", files[1], "-ms", "-j") + if err != nil { + return err + } + if len(results) != 2 { + return fmt.Errorf("unexpected number of results: %d (expected 2)", len(results)) + } + return nil +} diff --git a/integration_tests/protocols/http/matcher-status-and-cluster.yaml b/integration_tests/protocols/http/matcher-status-and-cluster.yaml new file mode 100644 index 0000000000..752531b90a --- /dev/null +++ b/integration_tests/protocols/http/matcher-status-and-cluster.yaml @@ -0,0 +1,23 @@ +id: matcher-status-and-cluster + +info: + name: Test Matcher Status AND Condition Cluster + author: pdteam + severity: info + +http: + - method: GET + path: + - "{{BaseURL}}/" + + stop-at-first-match: true + matchers-condition: and + matchers: + - type: word + part: body + words: + - "this_will_also_never_match" + + - type: status + status: + - 200 diff --git a/integration_tests/protocols/http/matcher-status-and.yaml b/integration_tests/protocols/http/matcher-status-and.yaml new file mode 100644 index 0000000000..60893c1820 --- /dev/null +++ b/integration_tests/protocols/http/matcher-status-and.yaml @@ -0,0 +1,23 @@ +id: matcher-status-and + +info: + name: Test Matcher Status AND Condition + author: pdteam + severity: info + +http: + - method: GET + path: + - "{{BaseURL}}/" + + stop-at-first-match: true + matchers-condition: and + matchers: + - type: word + part: body + words: + - "this_will_never_match_anything" + + - type: status + status: + - 200 diff --git a/pkg/templates/cluster.go b/pkg/templates/cluster.go index 46a0315346..889f736c20 100644 --- a/pkg/templates/cluster.go +++ b/pkg/templates/cluster.go @@ -4,6 +4,8 @@ import ( "fmt" "sort" "strings" + "sync/atomic" + "time" "github.com/projectdiscovery/gologger" "github.com/projectdiscovery/nuclei/v3/pkg/model" @@ -11,6 +13,7 @@ import ( "github.com/projectdiscovery/nuclei/v3/pkg/output" "github.com/projectdiscovery/nuclei/v3/pkg/protocols" "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/helpers/writer" + protocolUtils "github.com/projectdiscovery/nuclei/v3/pkg/protocols/utils" "github.com/projectdiscovery/nuclei/v3/pkg/scan" "github.com/projectdiscovery/nuclei/v3/pkg/templates/types" cryptoutil "github.com/projectdiscovery/utils/crypto" @@ -243,9 +246,13 @@ func (e *ClusterExecuter) Execute(ctx *scan.ScanContext) (bool, error) { } previous := make(map[string]interface{}) dynamicValues := make(map[string]interface{}) + + // Track if callback was invoked + callbackCalled := &atomic.Bool{} + err := e.requests.ExecuteWithResults(inputItem, dynamicValues, previous, func(event *output.InternalWrappedEvent) { + callbackCalled.Store(true) if event == nil { - // unlikely but just in case return } if event.InternalEvent == nil { @@ -259,21 +266,56 @@ func (e *ClusterExecuter) Execute(ctx *scan.ScanContext) (bool, error) { clonedEvent.InternalEvent["template-path"] = operator.templatePath clonedEvent.InternalEvent["template-info"] = operator.templateInfo - if result == nil && !matched && e.options.Options.MatcherStatus { - if err := e.options.Output.WriteFailure(clonedEvent); err != nil { - gologger.Warning().Msgf("Could not write failure event to output: %s\n", err) - } - continue - } if matched && result != nil { clonedEvent.OperatorsResult = result clonedEvent.Results = e.requests.MakeResultEvent(clonedEvent) results = true _ = writer.WriteResult(clonedEvent, e.options.Output, e.options.Progress, e.options.IssuesClient) + } else if !matched && e.options.Options.MatcherStatus { + if err := e.options.Output.WriteFailure(clonedEvent); err != nil { + gologger.Warning().Msgf("Could not write failure event to output: %s\n", err) + } } } }) + + // Fallback: if callback was never called and matcher-status is enabled, + // write failure events for each operator in the cluster + if !callbackCalled.Load() && e.options.Options.MatcherStatus { + // Parse URL fields from the input + fields := protocolUtils.GetJsonFieldsFromURL(ctx.Input.MetaInput.Input) + for _, operator := range e.operators { + errMsg := "" + if err != nil { + errMsg = err.Error() + } + fakeEvent := &output.InternalWrappedEvent{ + Results: []*output.ResultEvent{ + { + TemplateID: operator.templateID, + TemplatePath: operator.templatePath, + Info: operator.templateInfo, + Type: e.templateType.String(), + Host: fields.Host, + Port: fields.Port, + Scheme: fields.Scheme, + URL: fields.URL, + Path: fields.Path, + Timestamp: time.Now(), + Error: errMsg, + }, + }, + OperatorsResult: &operators.Result{ + Matched: false, + }, + } + if err := e.options.Output.WriteFailure(fakeEvent); err != nil { + gologger.Warning().Msgf("Could not write failure event to output: %s\n", err) + } + } + } + if e.options.HostErrorsCache != nil { e.options.HostErrorsCache.MarkFailedOrRemove(e.options.ProtocolType.String(), ctx.Input, err) } diff --git a/pkg/tmplexec/exec.go b/pkg/tmplexec/exec.go index acf72a3c0d..1af555429c 100644 --- a/pkg/tmplexec/exec.go +++ b/pkg/tmplexec/exec.go @@ -15,6 +15,7 @@ import ( "github.com/projectdiscovery/nuclei/v3/pkg/output" "github.com/projectdiscovery/nuclei/v3/pkg/protocols" "github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/helpers/writer" + protocolUtils "github.com/projectdiscovery/nuclei/v3/pkg/protocols/utils" "github.com/projectdiscovery/nuclei/v3/pkg/scan" "github.com/projectdiscovery/nuclei/v3/pkg/scan/events" "github.com/projectdiscovery/nuclei/v3/pkg/tmplexec/flow" @@ -222,18 +223,29 @@ func (e *TemplateExecuter) Execute(ctx *scan.ScanContext) (bool, error) { writeFailureCallback(lastMatcherEvent, e.options.Options.MatcherStatus) } - //TODO: this is a hacky way to handle the case where the callback is not called and matcher-status is true. - // This is a workaround and needs to be refactored. - // Check if callback was never called and matcher-status is true + // Fallback: if callback was never called and matcher-status is enabled, + // create a synthetic failure event. This handles edge cases where protocol + // implementations return early without invoking the callback (e.g., context + // cancellation, early validation errors). Most error paths now invoke the + // callback directly, but this remains as a safety net. + // Note: ClusterExecuter has equivalent fallback logic in pkg/templates/cluster.go if !callbackCalled.Load() && e.options.Options.MatcherStatus { + // Parse URL fields from the input + fields := protocolUtils.GetJsonFieldsFromURL(ctx.Input.MetaInput.Input) fakeEvent := &output.InternalWrappedEvent{ Results: []*output.ResultEvent{ { - TemplateID: e.options.TemplateID, - Info: e.options.TemplateInfo, - Type: e.getTemplateType(), - Host: ctx.Input.MetaInput.Input, - Error: getErrorCause(ctx.GenerateErrorMessage()), + TemplateID: e.options.TemplateID, + TemplatePath: e.options.TemplatePath, + Info: e.options.TemplateInfo, + Type: e.getTemplateType(), + Host: fields.Host, + Port: fields.Port, + Scheme: fields.Scheme, + URL: fields.URL, + Path: fields.Path, + Timestamp: time.Now(), + Error: getErrorCause(ctx.GenerateErrorMessage()), }, }, OperatorsResult: &operators.Result{