diff --git a/cmd/nuclei/main_benchmark_test.go b/cmd/nuclei/main_benchmark_test.go index 26d7a1965b..8de691bb5a 100644 --- a/cmd/nuclei/main_benchmark_test.go +++ b/cmd/nuclei/main_benchmark_test.go @@ -34,10 +34,15 @@ func TestMain(m *testing.M) { panic(err) } - dummyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNoContent) - })) - targetURL = dummyServer.URL + // Only start the shared httptest for benchmarks so unit tests (e.g. goleak) + // are not polluted by a long-lived accept loop. + var dummyServer *httptest.Server + if isBenchmarkRun() { + dummyServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + targetURL = dummyServer.URL + } // Execute tests @@ -45,13 +50,24 @@ func TestMain(m *testing.M) { // Tear down - dummyServer.Close() + if dummyServer != nil { + dummyServer.Close() + } _ = os.RemoveAll(projectPath) _ = os.Unsetenv("DISABLE_STDOUT") os.Exit(exitCode) } +func isBenchmarkRun() bool { + for _, arg := range os.Args[1:] { + if strings.HasPrefix(arg, "-test.bench") { + return true + } + } + return false +} + // getUniqFilename generates a unique filename by appending .N if file exists // Similar to wget's behavior: file.cpu.prof, file.cpu.1.prof, file.cpu.2.prof, etc. func getUniqFilename(basePath string) string { diff --git a/cmd/nuclei/main_leak_test.go b/cmd/nuclei/main_leak_test.go new file mode 100644 index 0000000000..f495b2d4a3 --- /dev/null +++ b/cmd/nuclei/main_leak_test.go @@ -0,0 +1,90 @@ +package main_test + +import ( + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/projectdiscovery/gologger" + "github.com/projectdiscovery/gologger/levels" + "github.com/projectdiscovery/nuclei/v3/internal/runner" + "github.com/projectdiscovery/nuclei/v3/pkg/catalog/config" + "github.com/rs/xid" + "github.com/stretchr/testify/require" + "github.com/tarunKoyalwar/goleak" +) + +const cliLeakTestChildEnv = "NUCLEI_CLI_LEAKTEST_CHILD" + +var cliKnownLeaks = []goleak.Option{ + goleak.Pretty(), + goleak.IgnoreAnyFunction("net/http.(*http2ClientConn).readLoop"), + goleak.IgnoreAnyFunction("net/http.(*persistConn).readLoop"), + goleak.IgnoreAnyFunction("net/http.(*persistConn).writeLoop"), + goleak.IgnoreAnyContainingPkg("github.com/hashicorp/golang-lru/v2/expirable"), + goleak.IgnoreAnyContainingPkg("github.com/syndtr/goleveldb"), + goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/ratelimit"), + goleak.IgnoreAnyFunction("github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate.StartActiveMemGuardian.func1"), +} + +// TestCLIRunnerGoroutineLeak runs a minimal CLI-path nuclei scan via internal/runner +// and asserts no unexpected goroutine leaks remain after Close. +// +// The body runs in a child process so shared package state from other tests +// does not pollute goleak (same approach as lib/tests SDK leak coverage). +func TestCLIRunnerGoroutineLeak(t *testing.T) { + if os.Getenv(cliLeakTestChildEnv) == "1" { + runCLIRunnerLeakTest(t) + return + } + + cmd := exec.Command(os.Args[0], "-test.run=^TestCLIRunnerGoroutineLeak$", "-test.count=1", "-test.v") + cmd.Env = append(os.Environ(), cliLeakTestChildEnv+"=1") + out, err := cmd.CombinedOutput() + require.NoError(t, err, "CLI leak test child failed:\n%s", string(out)) +} + +func runCLIRunnerLeakTest(t *testing.T) { + gologger.DefaultLogger.SetMaxLevel(levels.LevelSilent) + _ = os.Setenv("DISABLE_STDOUT", "true") + t.Cleanup(func() { _ = os.Unsetenv("DISABLE_STDOUT") }) + config.DefaultConfig.DisableUpdateCheck() + + defer func() { + time.Sleep(2 * time.Second) + goleak.VerifyNone(t, cliKnownLeaks...) + }() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + _, thisFile, _, ok := runtime.Caller(0) + require.True(t, ok) + templatePath := filepath.Join(filepath.Dir(thisFile), "testdata", "leaktest", "basic-http.yaml") + + options := getDefaultOptions() + options.Targets = []string{server.URL} + options.Templates = []string{templatePath} + options.NoInteractsh = true + options.DisableStdin = true + options.BulkSize = 1 + options.TemplateThreads = 1 + options.PayloadConcurrency = 1 + options.ExecutionId = xid.New().String() + + runner.ParseOptions(options) + + nucleiRunner, err := runner.New(options) + require.NoError(t, err) + require.NotNil(t, nucleiRunner) + defer nucleiRunner.Close() + + require.NoError(t, nucleiRunner.RunEnumeration()) +} diff --git a/cmd/nuclei/testdata/leaktest/basic-http.yaml b/cmd/nuclei/testdata/leaktest/basic-http.yaml new file mode 100644 index 0000000000..c844653ffc --- /dev/null +++ b/cmd/nuclei/testdata/leaktest/basic-http.yaml @@ -0,0 +1,16 @@ +id: leaktest-basic-http + +info: + name: CLI Leak Test Basic HTTP + author: pdteam + severity: info + +http: + - method: GET + path: + - "{{BaseURL}}/" + + matchers: + - type: status + status: + - 204