Repository: projectdiscovery/nuclei

Issue:
[BUG] headless launch takes a lot of time
### Is there an existing issue for this?

- [x] I have searched the existing issues.

### Current Behavior

When launching a headless template, it takes a lot of time (up to 5 minutes) for nuclei to start the headless browser. Closing the test also takes a lot of time.

I assume that this happens because I have quite a lot of chrome / Chromium processes running on my system (MS Teams, VS Code, Burp, Chrome itself) and the current implementation tries to enumerate these processes on each start and shutdown of the headless engine.

### Expected Behavior

Templates executed with the headless engine start and terminate reasonibly fast, regardless how many chrome processes are currently active.

### Steps To Reproduce

1. Launch a lot of chrome processes
2. Run a headless nuclei template with the `-headless` switch

### Relevant log output

```shell

```

### Environment

```markdown
- OS: MacOS
- Nuclei: v3.4.3
- Go: v1.24.1
```

### Anything else?

I'll create a PR with a fix right away.

Discussion:

Solution patch:
diff --git a/.github/stale.yml b/.github/stale.yml
deleted file mode 100644
index 75d7ee02..00000000
--- a/.github/stale.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-# Number of days of inactivity before an issue becomes stale
-daysUntilStale: 7
-
-# Number of days of inactivity before a stale issue is closed
-daysUntilClose: 7
-
-# Issues with these labels will never be considered stale
-# exemptLabels:
-#   - pinned
-#   - security
-
-# Only issues or pull requests with all of these labels are check if stale.
-onlyLabels:
-  - "Status: Abandoned"
-  - "Type: Question"
-
-# Label to use when marking as stale
-staleLabel: stale
-
-# Comment to post when marking an issue as stale. Set to `false` to disable
-markComment: >
-  This issue has been automatically marked as stale because it has not had
-  recent activity. It will be closed if no further activity occurs. Thank you
-  for your contributions.
-
-# Comment to post when closing a stale issue. Set to `false` to disable
-closeComment: false
\ No newline at end of file
diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml
new file mode 100644
index 00000000..2b336b67
--- /dev/null
+++ b/.github/workflows/stale.yaml
@@ -0,0 +1,41 @@
+name: 💤 Stale
+
+on:
+  schedule:
+    - cron: '0 0 * * 0' # Weekly
+
+jobs:
+  stale:
+    runs-on: ubuntu-latest
+    permissions:
+      actions: write
+      contents: write # only for delete-branch option
+      issues: write
+      pull-requests: write
+    steps:
+      - uses: actions/stale@v9
+        with:
+          days-before-stale: 90
+          days-before-close: 7
+          stale-issue-label: "Status: Stale"
+          stale-pr-label: "Status: Stale"
+          stale-issue-message: >
+            This issue has been automatically marked as stale because it has not
+            had recent activity. It will be closed in 7 days if no further
+            activity occurs. Thank you for your contributions!
+          stale-pr-message: >
+            This pull request has been automatically marked as stale due to
+            inactivity. It will be closed in 7 days if no further activity
+            occurs. Please update if you wish to keep it open.
+          close-issue-message: >
+            This issue has been automatically closed due to inactivity. If you
+            think this is a mistake or would like to continue the discussion,
+            please comment or feel free to reopen it.
+          close-pr-message: >
+            This pull request has been automatically closed due to inactivity.
+            If you think this is a mistake or would like to continue working on
+            it, please comment or feel free to reopen it.
+          close-issue-label: "Status: Abandoned"
+          close-pr-label: "Status: Abandoned"
+          exempt-issue-labels: "Status: Abandoned"
+          exempt-pr-labels: "Status: Abandoned"
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index af6ad9d7..7cbfd2f1 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -94,7 +94,7 @@ jobs:
       - uses: actions/checkout@v4
       - uses: projectdiscovery/actions/setup/go@v1
       - uses: projectdiscovery/actions/setup/python@v1
-      - run: bash run.sh "${{ matrix.os }}"
+      - run: bash run.sh
         env:
           GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
         working-directory: cmd/functional-test/
diff --git a/Dockerfile b/Dockerfile
index 9d7a780c..1c44e705 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
 # Build
-FROM golang:1.22-alpine AS builder
+FROM golang:1.23-alpine AS builder
 
 RUN apk add build-base
 WORKDIR /app
@@ -13,4 +13,4 @@ FROM alpine:latest
 RUN apk add --no-cache bind-tools chromium ca-certificates
 COPY --from=builder /app/bin/nuclei /usr/local/bin/
 
-ENTRYPOINT ["nuclei"]
\ No newline at end of file
+ENTRYPOINT ["nuclei"]
diff --git a/README.md b/README.md
index fa76e369..1153b8e1 100644
--- a/README.md
+++ b/README.md
@@ -111,7 +111,7 @@ Browse the full Nuclei [**`documentation here`**](https://docs.projectdiscovery.
 
 ### Installation
 
-`nuclei` requires **go1.22** to install successfully. Run the following command to get the repo:
+`nuclei` requires **go1.23** to install successfully. Run the following command to get the repo:
 
 ```sh
 go install -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest
diff --git a/pkg/catalog/config/constants.go b/pkg/catalog/config/constants.go
index 3b497658..147cf23a 100644
--- a/pkg/catalog/config/constants.go
+++ b/pkg/catalog/config/constants.go
@@ -31,7 +31,7 @@ const (
 	CLIConfigFileName               = "config.yaml"
 	ReportingConfigFilename         = "reporting-config.yaml"
 	// Version is the current version of nuclei
-	Version = `v3.4.3`
+	Version = `v3.4.4`
 	// Directory Names of custom templates
 	CustomS3TemplatesDirName     = "s3"
 	CustomGitHubTemplatesDirName = "github"
diff --git a/pkg/input/formats/openapi/examples.go b/pkg/input/formats/openapi/examples.go
index 9e7224ab..235f940c 100644
--- a/pkg/input/formats/openapi/examples.go
+++ b/pkg/input/formats/openapi/examples.go
@@ -288,3 +288,33 @@ func openAPIExample(schema *openapi3.Schema, cache map[*openapi3.Schema]*cachedS
 func generateExampleFromSchema(schema *openapi3.Schema) (interface{}, error) {
 	return openAPIExample(schema, make(map[*openapi3.Schema]*cachedSchema)) // TODO: Use caching
 }
+
+func generateEmptySchemaValue(contentType string) *openapi3.Schema {
+	schema := &openapi3.Schema{}
+	objectType := &openapi3.Types{"object"}
+	stringType := &openapi3.Types{"string"}
+
+	switch contentType {
+	case "application/json":
+		schema.Type = objectType
+		schema.Properties = make(map[string]*openapi3.SchemaRef)
+	case "application/xml":
+		schema.Type = stringType
+		schema.Format = "xml"
+		schema.Example = "<?xml version=\"1.0\"?><root/>"
+	case "text/plain":
+		schema.Type = stringType
+	case "application/x-www-form-urlencoded":
+		schema.Type = objectType
+		schema.Properties = make(map[string]*openapi3.SchemaRef)
+	case "multipart/form-data":
+		schema.Type = objectType
+		schema.Properties = make(map[string]*openapi3.SchemaRef)
+	case "application/octet-stream":
+	default:
+		schema.Type = stringType
+		schema.Format = "binary"
+	}
+
+	return schema
+}
diff --git a/pkg/input/formats/openapi/generator.go b/pkg/input/formats/openapi/generator.go
index 9c44797d..1906858c 100644
--- a/pkg/input/formats/openapi/generator.go
+++ b/pkg/input/formats/openapi/generator.go
@@ -268,24 +268,32 @@ func generateRequestsFromOp(opts *generateReqOptions) error {
 		for content, value := range opts.op.RequestBody.Value.Content {
 			cloned := req.Clone(req.Context())
 
-			example, err := generateExampleFromSchema(value.Schema.Value)
-			if err != nil {
-				continue
+			var val interface{}
+
+			if value.Schema == nil || value.Schema.Value == nil {
+				val = generateEmptySchemaValue(content)
+			} else {
+				var err error
+
+				val, err = generateExampleFromSchema(value.Schema.Value)
+				if err != nil {
+					continue
+				}
 			}
 
 			// var body string
 			switch content {
 			case "application/json":
-				if marshalled, err := json.Marshal(example); err == nil {
+				if marshalled, err := json.Marshal(val); err == nil {
 					// body = string(marshalled)
 					cloned.Body = io.NopCloser(bytes.NewReader(marshalled))
 					cloned.ContentLength = int64(len(marshalled))
 					cloned.Header.Set("Content-Type", "application/json")
 				}
 			case "application/xml":
-				exampleVal := mxj.Map(example.(map[string]interface{}))
+				values := mxj.Map(val.(map[string]interface{}))
 
-				if marshalled, err := exampleVal.Xml(); err == nil {
+				if marshalled, err := values.Xml(); err == nil {
 					// body = string(marshalled)
 					cloned.Body = io.NopCloser(bytes.NewReader(marshalled))
 					cloned.ContentLength = int64(len(marshalled))
@@ -294,7 +302,7 @@ func generateRequestsFromOp(opts *generateReqOptions) error {
 					gologger.Warning().Msgf("openapi: could not encode xml")
 				}
 			case "application/x-www-form-urlencoded":
-				if values, ok := example.(map[string]interface{}); ok {
+				if values, ok := val.(map[string]interface{}); ok {
 					cloned.Form = url.Values{}
 					for k, v := range values {
 						cloned.Form.Set(k, types.ToString(v))
@@ -306,7 +314,7 @@ func generateRequestsFromOp(opts *generateReqOptions) error {
 					cloned.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 				}
 			case "multipart/form-data":
-				if values, ok := example.(map[string]interface{}); ok {
+				if values, ok := val.(map[string]interface{}); ok {
 					buffer := &bytes.Buffer{}
 					multipartWriter := multipart.NewWriter(buffer)
 					for k, v := range values {
@@ -326,13 +334,13 @@ func generateRequestsFromOp(opts *generateReqOptions) error {
 					cloned.Header.Set("Content-Type", multipartWriter.FormDataContentType())
 				}
 			case "text/plain":
-				str := types.ToString(example)
+				str := types.ToString(val)
 				// body = str
 				cloned.Body = io.NopCloser(strings.NewReader(str))
 				cloned.ContentLength = int64(len(str))
 				cloned.Header.Set("Content-Type", "text/plain")
 			case "application/octet-stream":
-				str := types.ToString(example)
+				str := types.ToString(val)
 				if str == "" {
 					// use two strings
 					str = "string1\nstring2"
diff --git a/pkg/protocols/headless/engine/engine.go b/pkg/protocols/headless/engine/engine.go
index 20942c26..0a83eef9 100644
--- a/pkg/protocols/headless/engine/engine.go
+++ b/pkg/protocols/headless/engine/engine.go
@@ -15,16 +15,15 @@ import (
 	"github.com/projectdiscovery/nuclei/v3/pkg/types"
 	fileutil "github.com/projectdiscovery/utils/file"
 	osutils "github.com/projectdiscovery/utils/os"
-	processutil "github.com/projectdiscovery/utils/process"
 )
 
 // Browser is a browser structure for nuclei headless module
 type Browser struct {
-	customAgent  string
-	tempDir      string
-	previousPIDs map[int32]struct{} // track already running PIDs
-	engine       *rod.Browser
-	options      *types.Options
+	customAgent string
+	tempDir     string
+	engine      *rod.Browser
+	options     *types.Options
+	launcher    *launcher.Launcher
 	// use getHTTPClient to get the http client
 	httpClient     *http.Client
 	httpClientOnce *sync.Once
@@ -36,7 +35,6 @@ func New(options *types.Options) (*Browser, error) {
 	if err != nil {
 		return nil, errors.Wrap(err, "could not create temporary directory")
 	}
-	previousPIDs := processutil.FindProcesses(processutil.IsChromeProcess)
 
 	chromeLauncher := launcher.New().
 		Leakless(false).
@@ -110,8 +108,8 @@ func New(options *types.Options) (*Browser, error) {
 		engine:         browser,
 		options:        options,
 		httpClientOnce: &sync.Once{},
+		launcher:       chromeLauncher,
 	}
-	engine.previousPIDs = previousPIDs
 	return engine, nil
 }
 
@@ -143,6 +141,6 @@ func (b *Browser) getHTTPClient() (*http.Client, error) {
 // Close closes the browser engine
 func (b *Browser) Close() {
 	b.engine.Close()
+	b.launcher.Kill()
 	os.RemoveAll(b.tempDir)
-	processutil.CloseProcesses(processutil.IsChromeProcess, b.previousPIDs)
 }
diff --git a/pkg/protocols/http/httpclientpool/clientpool.go b/pkg/protocols/http/httpclientpool/clientpool.go
index 3f10fcba..5c1a91cb 100644
--- a/pkg/protocols/http/httpclientpool/clientpool.go
+++ b/pkg/protocols/http/httpclientpool/clientpool.go
@@ -307,6 +307,14 @@ func wrappedGet(options *types.Options, configuration *Configuration) (*retryabl
 			if err != nil {
 				return nil, err
 			}
+			if tlsConfig.ServerName == "" {
+				// addr should be in form of host:port already set from canonicalAddr
+				host, _, err := net.SplitHostPort(addr)
+				if err != nil {
+					return nil, err
+				}
+				tlsConfig.ServerName = host
+			}
 			return tls.Client(conn, tlsConfig), nil
 		}
 	}



Test patch:
diff --git a/cmd/functional-test/main.go b/cmd/functional-test/main.go
index 8a5cebc5..78114fdb 100644
--- a/cmd/functional-test/main.go
+++ b/cmd/functional-test/main.go
@@ -27,7 +27,7 @@ var (
 func main() {
 	flag.Parse()
 
-	debug := os.Getenv("DEBUG") == "true"
+	debug := os.Getenv("DEBUG") == "true" || os.Getenv("RUNNER_DEBUG") == "1"
 
 	if err, errored := runFunctionalTests(debug); err != nil {
 		log.Fatalf("Could not run functional tests: %s\n", err)
diff --git a/cmd/functional-test/run.sh b/cmd/functional-test/run.sh
index a3caf722..a955ad8a 100755
--- a/cmd/functional-test/run.sh
+++ b/cmd/functional-test/run.sh
@@ -1,27 +1,43 @@
 #!/bin/bash
 
-# reading os type from arguments
-CURRENT_OS=$1
+if [ "${RUNNER_OS}" == "Windows" ]; then
+    EXT=".exe"
+elif [ "${RUNNER_OS}" == "macOS" ]; then
+    if [ "${CI}" == "true" ]; then
+        sudo sysctl -w kern.maxfiles{,perproc}=524288
+        sudo launchctl limit maxfiles 65536 524288
+    fi
 
-if [ "${CURRENT_OS}" == "windows-latest" ];then
-    extension=.exe
+    ORIGINAL_ULIMIT="$(ulimit -n)"
+    ulimit -n 65536 || true
 fi
 
+mkdir -p .nuclei-config/nuclei/
+touch .nuclei-config/nuclei/.nuclei-ignore
+
 echo "::group::Building functional-test binary"
-go build -o functional-test$extension
+go build -o "functional-test${EXT}"
 echo "::endgroup::"
 
 echo "::group::Building Nuclei binary from current branch"
-go build -o nuclei_dev$extension ../nuclei
+go build -o "nuclei-dev${EXT}" ../nuclei
+echo "::endgroup::"
+
+echo "::group::Building latest release of nuclei"
+go build -o "nuclei${EXT}" -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei
 echo "::endgroup::"
 
 echo "::group::Installing nuclei templates"
-./nuclei_dev$extension -update-templates
+eval "./nuclei-dev${EXT} -update-templates"
 echo "::endgroup::"
 
-echo "::group::Building latest release of nuclei"
-go build -o nuclei$extension -v github.com/projectdiscovery/nuclei/v3/cmd/nuclei
+echo "::group::Validating templates"
+eval "./nuclei-dev${EXT} -validate"
 echo "::endgroup::"
 
-echo 'Starting Nuclei functional test'
-./functional-test$extension -main ./nuclei$extension -dev ./nuclei_dev$extension -testcases testcases.txt
+echo "Starting Nuclei functional test"
+eval "./functional-test${EXT} -main ./nuclei${EXT} -dev ./nuclei-dev${EXT} -testcases testcases.txt"
+
+if [ "${RUNNER_OS}" == "macOS" ]; then
+    ulimit -n "${ORIGINAL_ULIMIT}" || true
+fi