Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions cmd/integration-test/matcher-status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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{}
Expand Down Expand Up @@ -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
}
23 changes: 23 additions & 0 deletions integration_tests/protocols/http/matcher-status-and-cluster.yaml
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions integration_tests/protocols/http/matcher-status-and.yaml
Original file line number Diff line number Diff line change
@@ -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
56 changes: 49 additions & 7 deletions pkg/templates/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import (
"fmt"
"sort"
"strings"
"sync/atomic"
"time"

"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/model"
"github.com/projectdiscovery/nuclei/v3/pkg/operators"
"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"
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down
28 changes: 20 additions & 8 deletions pkg/tmplexec/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{
Expand Down
Loading