Repository: projectdiscovery/nuclei

Issue:
Add support for multiple default ports in JavaScript templates
This issue tracks the capability to support multiple default ports in JavaScript templates within the Nuclei engine.

**Context:**
This enhancement is being implemented in PR #6501 by @pussycat0x.

**Related Links:**
- Pull Request: https://github.com/projectdiscovery/nuclei/pull/6501
- Requested by: @Mzack9999 in https://github.com/projectdiscovery/nuclei/pull/6501#discussion_r1938741028

**Description:**
The capability allows JavaScript templates to specify and test multiple ports, enabling more flexible network protocol testing scenarios. This is particularly useful for protocols that may run on various default ports (e.g., SSH on ports 22, 222, 2244).

**Example Usage:**
Templates can now specify multiple ports in the Port argument, and the engine will test each port accordingly.

cc: @pussycat0x @Mzack9999

Discussion:

Solution patch:
diff --git a/lib/config.go b/lib/config.go
index cdc56ce0..f624ef42 100644
--- a/lib/config.go
+++ b/lib/config.go
@@ -3,6 +3,7 @@ package nuclei
 import (
 	"context"
 	"errors"
+	"os"
 	"time"
 
 	"github.com/projectdiscovery/goflags"
@@ -559,3 +560,18 @@ func WithOptions(opts *pkgtypes.Options) NucleiSDKOptions {
 		return nil
 	}
 }
+
+// WithTemporaryDirectory allows setting a parent directory for SDK-managed temporary files.
+// A temporary directory will be created inside the provided directory and cleaned up on engine close.
+// If not set, a temporary directory will be automatically created in the system temp location.
+// The parent directory is assumed to exist.
+func WithTemporaryDirectory(parentDir string) NucleiSDKOptions {
+	return func(e *NucleiEngine) error {
+		tmpDir, err := os.MkdirTemp(parentDir, "nuclei-tmp-*")
+		if err != nil {
+			return err
+		}
+		e.tmpDir = tmpDir
+		return nil
+	}
+}
diff --git a/lib/sdk.go b/lib/sdk.go
index 3ed25217..99523c79 100644
--- a/lib/sdk.go
+++ b/lib/sdk.go
@@ -5,6 +5,7 @@ import (
 	"bytes"
 	"context"
 	"io"
+	"os"
 	"sync"
 
 	"github.com/projectdiscovery/gologger"
@@ -92,6 +93,9 @@ type NucleiEngine struct {
 
 	// Logger instance for the engine
 	Logger *gologger.Logger
+
+	// Temporary directory for SDK-managed template files
+	tmpDir string
 }
 
 // LoadAllTemplates loads all nuclei template based on given options
@@ -231,6 +235,9 @@ func (e *NucleiEngine) closeInternal() {
 	if e.httpxClient != nil {
 		_ = e.httpxClient.Close()
 	}
+	if e.tmpDir != "" {
+		_ = os.RemoveAll(e.tmpDir)
+	}
 }
 
 // Close all resources used by nuclei engine
diff --git a/lib/sdk_private.go b/lib/sdk_private.go
index d80a0fd0..ba394f02 100644
--- a/lib/sdk_private.go
+++ b/lib/sdk_private.go
@@ -3,6 +3,7 @@ package nuclei
 import (
 	"context"
 	"fmt"
+	"os"
 	"strings"
 	"sync"
 	"time"
@@ -170,20 +171,29 @@ func (e *NucleiEngine) init(ctx context.Context) error {
 		e.catalog = disk.NewCatalog(config.DefaultConfig.TemplatesDirectory)
 	}
 
+	if e.tmpDir == "" {
+		tmpDir, err := os.MkdirTemp("", "nuclei-tmp-*")
+		if err != nil {
+			return err
+		}
+		e.tmpDir = tmpDir
+	}
+
 	e.executerOpts = &protocols.ExecutorOptions{
-		Output:       e.customWriter,
-		Options:      e.opts,
-		Progress:     e.customProgress,
-		Catalog:      e.catalog,
-		IssuesClient: e.rc,
-		RateLimiter:  e.rateLimiter,
-		Interactsh:   e.interactshClient,
-		Colorizer:    aurora.NewAurora(true),
-		ResumeCfg:    types.NewResumeCfg(),
-		Browser:      e.browserInstance,
-		Parser:       e.parser,
-		InputHelper:  input.NewHelper(),
-		Logger:       e.opts.Logger,
+		Output:             e.customWriter,
+		Options:            e.opts,
+		Progress:           e.customProgress,
+		Catalog:            e.catalog,
+		IssuesClient:       e.rc,
+		RateLimiter:        e.rateLimiter,
+		Interactsh:         e.interactshClient,
+		Colorizer:          aurora.NewAurora(true),
+		ResumeCfg:          types.NewResumeCfg(),
+		Browser:            e.browserInstance,
+		Parser:             e.parser,
+		InputHelper:        input.NewHelper(),
+		TemporaryDirectory: e.tmpDir,
+		Logger:             e.opts.Logger,
 	}
 	if e.opts.ShouldUseHostError() && e.hostErrCache != nil {
 		e.executerOpts.HostErrorsCache = e.hostErrCache
diff --git a/pkg/protocols/javascript/js.go b/pkg/protocols/javascript/js.go
index 8b872d84..2d927555 100644
--- a/pkg/protocols/javascript/js.go
+++ b/pkg/protocols/javascript/js.go
@@ -36,6 +36,7 @@ import (
 	"github.com/projectdiscovery/utils/errkit"
 	iputil "github.com/projectdiscovery/utils/ip"
 	mapsutil "github.com/projectdiscovery/utils/maps"
+	sliceutil "github.com/projectdiscovery/utils/slice"
 	syncutil "github.com/projectdiscovery/utils/sync"
 	urlutil "github.com/projectdiscovery/utils/url"
 )
@@ -133,8 +134,11 @@ func (request *Request) Compile(options *protocols.ExecutorOptions) error {
 	}
 
 	// "Port" is a special variable and it should not contains any dsl expressions
-	if strings.Contains(request.getPort(), "{{") {
-		return errkit.New("'Port' variable cannot contain any dsl expressions")
+	ports := request.getPorts()
+	for _, port := range ports {
+		if strings.Contains(port, "{{") {
+			return errkit.New("'Port' variable cannot contain any dsl expressions")
+		}
 	}
 
 	if request.Init != "" {
@@ -281,12 +285,28 @@ func (request *Request) GetID() string {
 
 // ExecuteWithResults executes the protocol requests and returns results instead of writing them.
 func (request *Request) ExecuteWithResults(target *contextargs.Context, dynamicValues, previous output.InternalEvent, callback protocols.OutputEventCallback) error {
+	// Get default port(s) if specified in template
+	ports := request.getPorts()
+
+	var errs []error
+
+	for _, port := range ports {
+		err := request.executeWithResults(port, target, dynamicValues, previous, callback)
+		if err != nil {
+			errs = append(errs, err)
+		}
+	}
 
+	return errkit.Join(errs...)
+}
+
+// executeWithResults executes the request
+func (request *Request) executeWithResults(port string, target *contextargs.Context, dynamicValues, previous output.InternalEvent, callback protocols.OutputEventCallback) error {
 	input := target.Clone()
 	// use network port updates input with new port requested in template file
 	// and it is ignored if input port is not standard http(s) ports like 80,8080,8081 etc
 	// idea is to reduce redundant dials to http ports
-	if err := input.UseNetworkPort(request.getPort(), request.getExcludePorts()); err != nil {
+	if err := input.UseNetworkPort(port, request.getExcludePorts()); err != nil {
 		gologger.Debug().Msgf("Could not network port from constants: %s\n", err)
 	}
 
@@ -755,13 +775,21 @@ func (request *Request) Type() templateTypes.ProtocolType {
 	return templateTypes.JavascriptProtocol
 }
 
-func (request *Request) getPort() string {
+func (request *Request) getPorts() []string {
 	for k, v := range request.Args {
 		if strings.EqualFold(k, "Port") {
-			return types.ToString(v)
+			portStr := types.ToString(v)
+			ports := []string{}
+			for _, p := range strings.Split(portStr, ",") {
+				trimmed := strings.TrimSpace(p)
+				if trimmed != "" {
+					ports = append(ports, trimmed)
+				}
+			}
+			return sliceutil.Dedupe(ports)
 		}
 	}
-	return ""
+	return []string{}
 }
 
 func (request *Request) getExcludePorts() string {



Test patch:
diff --git a/cmd/integration-test/javascript.go b/cmd/integration-test/javascript.go
index 6e99b7f8..c0b8c199 100644
--- a/cmd/integration-test/javascript.go
+++ b/cmd/integration-test/javascript.go
@@ -17,6 +17,7 @@ var jsTestcases = []TestCaseInfo{
 	{Path: "protocols/javascript/net-https.yaml", TestCase: &javascriptNetHttps{}},
 	{Path: "protocols/javascript/oracle-auth-test.yaml", TestCase: &javascriptOracleAuthTest{}, DisableOn: func() bool { return osutils.IsWindows() || osutils.IsOSX() }},
 	{Path: "protocols/javascript/vnc-pass-brute.yaml", TestCase: &javascriptVncPassBrute{}},
+	{Path: "protocols/javascript/multi-ports.yaml", TestCase: &javascriptMultiPortsSSH{}},
 }
 
 var (
@@ -167,6 +168,17 @@ func (j *javascriptVncPassBrute) Execute(filePath string) error {
 	return multierr.Combine(errs...)
 }
 
+type javascriptMultiPortsSSH struct{}
+
+func (j *javascriptMultiPortsSSH) Execute(filePath string) error {
+	// use scanme.sh as target to ensure we match on the 2nd default port 22
+	results, err := testutils.RunNucleiTemplateAndGetResults(filePath, "scanme.sh", debug)
+	if err != nil {
+		return err
+	}
+	return expectResultsCount(results, 1)
+}
+
 // purge any given resource if it is not nil
 func purge(resource *dockertest.Resource) {
 	if resource != nil && pool != nil {
diff --git a/integration_tests/protocols/javascript/multi-ports.yaml b/integration_tests/protocols/javascript/multi-ports.yaml
new file mode 100644
index 00000000..aa1c5fe2
--- /dev/null
+++ b/integration_tests/protocols/javascript/multi-ports.yaml
@@ -0,0 +1,28 @@
+id: multi-ports
+
+info:
+  name: Multi Ports - Detection
+  author: pdteam
+  severity: info
+  description: |
+    Multi Ports template for testing
+  metadata:
+    max-request: 1
+  tags: js,detect,multi-ports,enum,network
+
+javascript:
+  - pre-condition: |
+      isPortOpen(Host,Port);
+    code: |
+      var m = require("nuclei/ssh");
+      var c = m.SSHClient();
+      var response = c.ConnectSSHInfoMode(Host, Port);
+      Export(response);
+    args:
+      Host: "{{Host}}"
+      Port: "2222,22" # Port 22 should match
+
+    extractors:
+      - type: json
+        json:
+          - '.UserAuth'
\ No newline at end of file