Compare commits

..
Author SHA1 Message Date
Mzack9999 3066e87d49 Escape paths 2026-10-26 15:25:17 +01:00
Mzack9999 2f9ec788d7 Bump gozero 2026-10-26 13:23:17 +01:00
Mzack9999 65af13f607 Bump gozero 2026-10-26 13:17:13 +01:00
Mzack9999 b63510f1eb Reuse gozero 2026-10-26 13:02:53 +01:00
Mzack9999 85c362a8e2 Register infiles 2026-10-26 12:35:33 +01:00
Mzack9999 f7bd7b7608 Scan tempdir 2026-10-26 12:32:07 +01:00
Mzack9999 04ed27ddad Drop helpers 2026-10-26 12:26:46 +01:00
Mzack9999 0f42ab5cb3 Native fallback 2026-10-26 12:26:29 +01:00
Mzack9999 44eb73b023 Gate landlock 2026-10-26 12:14:36 +01:00
Mzack9999 a51960362e Merge dev 2026-10-26 12:04:24 +01:00
Mzack9999 611104a75f Fix docs 2026-10-26 11:05:43 +01:00
Mzack9999 6d68c5e283 Deny config 2026-10-26 11:05:43 +01:00
Mzack9999 ed4fc56fcf Fix review 2026-10-25 23:45:14 +01:00
Mzack9999 52f6850cee Merge dev 2026-10-25 23:45:14 +01:00
Mzack9999 f50c176a5e browser profile 2026-09-14 19:25:59 +02:00
Mzack9999 609e52afe0 allow refer 2026-09-14 18:23:13 +02:00
Mzack9999 df8b2bb15d own roots 2026-09-14 17:34:35 +02:00
Mzack9999 c332c6dfa4 state dir 2026-09-14 17:20:19 +02:00
Mzack9999 23c9443b73 fix roots 2026-09-14 16:51:49 +02:00
Mzack9999 437e7f3b90 fix lint 2026-09-14 16:12:09 +02:00
Mzack9999 77dc5e7b01 merge sandbox 2026-09-14 15:58:40 +02:00
Mzack9999 45ef8963f4 fix tests 2026-08-26 12:52:51 +04:00
Mzack9999 a55e66b25a Merge remote-tracking branch 'origin/dev' into feat-snd 2026-08-26 12:41:13 +04:00
Mzack9999 596f0eb5ff fix http 2026-07-27 16:14:25 +04:00
Mzack9999 c36ccf80b1 merge origin/dev 2026-07-27 16:13:25 +04:00
Mzack9999 6777d4ea2d fix grpc 2026-07-27 15:51:01 +04:00
Mzack9999 0f38656acc merge origin/dev
# Conflicts:
#	go.mod
#	pkg/js/libs/dcerpc/transport_init.go
#	pkg/js/libs/mssql/mssql.go
#	pkg/js/libs/mysql/mysql.go
#	pkg/js/libs/smb/smb.go
2026-07-27 13:57:13 +04:00
Mzack9999 895ca210a9 review fixes 2026-07-06 01:35:29 +02:00
Mzack9999 08817012c5 portable paths 2026-07-05 18:12:07 +02:00
Mzack9999 57393657f8 Merge remote-tracking branch 'origin/dev' into feat-snd 2026-07-05 18:03:00 +02:00
Mzack9999 d75f65928d drop privileges 2026-07-05 17:30:12 +02:00
Mzack9999 5bab1c55bb silence staticcheck 2026-07-05 17:13:43 +02:00
Mzack9999 f88573c3e7 harden execution 2026-07-03 19:26:24 +02:00
Mzack9999 6414b78429 merge dev 2026-07-03 02:23:36 +02:00
Mzack9999 3e5f413a20 harden sandbox 2026-07-03 02:19:02 +02:00
120 changed files with 2905 additions and 4743 deletions

No files matched your search

+6 -1
View File
@@ -23,7 +23,7 @@ endif
.PHONY: all build build-stats clean devtools-all devtools-bindgen devtools-scrapefuncs fuzz fuzz-ci fuzz-tools
.PHONY: devtools-tsgen docs docgen dsl-docs functional go-build lint lint-strict fuzzplayground syntax-docs
.PHONY: integration integration-debug regression jsupdate-all jsupdate-bindgen jsupdate-tsgen memogen scan-charts test test-with-lint
.PHONY: integration integration-debug regression jsupdate-all jsupdate-bindgen jsupdate-tsgen memogen scan-charts test test-with-lint test-sandbox-linux
.PHONY: tidy ts verify download vet template-validate build-fuzz discover-fuzz-packages
all: build
@@ -99,6 +99,11 @@ integration:
integration-debug:
$(GOTEST) -tags=integration ./internal/tests/integration -v $(GO_TEST_ARGS) -args $(INTEGRATION_ARGS)
# Runs the Landlock sandbox tests inside Linux Docker (macOS/Windows hosts).
test-sandbox-linux:
docker run --rm -e GOTOOLCHAIN=auto -v "$(PWD):/src" -w /src golang:bookworm \
go test ./pkg/protocols/common/sandbox/... ./pkg/protocols/code/... -count=1 -run "Landlock|Apply|Supported|DisableSandbox"
# Opt-in HTTP engine scale regression harness (not part of CI). Stands up many
# loopback hosts and asserts finding parity across a diverse template set.
# Override host count with NUCLEI_SCALE_HOSTS, e.g. NUCLEI_SCALE_HOSTS=500 make regression
+3 -4
View File
@@ -246,7 +246,9 @@ CONFIGURATIONS:
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
-sni string tls sni hostname to use (default: input domain name)
-dka, -dialer-keep-alive value keep-alive duration for network requests.
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
-lfa, -allow-local-file-access allows local file access from additional allowed directories (cwd and --allowed-paths)
-ap, -allowed-paths string[] additional filesystem roots allowed with -lfa (repeatable)
-no-sandbox disable the OS-level filesystem sandbox and the code template sandbox
-lna, -restrict-local-network-access blocks connections to the local / private network
-i, -interface string network interface to use for network scan
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
@@ -323,10 +325,7 @@ OPTIMIZATIONS:
-stream stream mode - start elaborating without sorting the input
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-response-cache reuse identical safe GET/HEAD responses within a scan (lossless: skips requests with auth, cookies, body or custom headers)
-nh, -no-httpx disable httpx probing for non-url input
-stp, -strict-probe skip templates whose target service is unreachable: HTTP/headless on hosts httpx could not confirm and network templates on closed ports (lossless, no raw-input fallback)
-tf, -tech-filter opt-in: fingerprint HTTP targets and skip product/macro-tagged templates that cannot match (off by default; enables scan-scoped GET cache; fail-open; no-op if no tech-bound templates)
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
+3 -4
View File
@@ -244,7 +244,9 @@ CONFIGURATIONS:
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
-sni string tls sni hostname to use (default: input domain name)
-dka, -dialer-keep-alive value keep-alive duration for network requests.
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
-lfa, -allow-local-file-access allows local file access from additional allowed directories (cwd and --allowed-paths)
-ap, -allowed-paths string[] additional filesystem roots allowed with -lfa (repeatable)
-no-sandbox disable the OS-level filesystem sandbox and the code template sandbox
-lna, -restrict-local-network-access blocks connections to the local / private network
-i, -interface string network interface to use for network scan
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
@@ -318,10 +320,7 @@ OPTIMIZATIONS:
-stream stream mode - start elaborating without sorting the input
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-response-cache reuse identical safe GET/HEAD responses within a scan (lossless: skips requests with auth, cookies, body or custom headers)
-nh, -no-httpx disable httpx probing for non-url input
-stp, -strict-probe skip templates whose target service is unreachable: HTTP/headless on hosts httpx could not confirm and network templates on closed ports (lossless, no raw-input fallback)
-tf, -tech-filter opt-in: fingerprint HTTP targets and skip product/macro-tagged templates that cannot match (off by default; enables scan-scoped GET cache; fail-open; no-op if no tech-bound templates)
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
+3 -4
View File
@@ -244,7 +244,9 @@ CONFIGURATIONS:
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
-sni string tls sni hostname to use (default: input domain name)
-dka, -dialer-keep-alive value keep-alive duration for network requests.
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
-lfa, -allow-local-file-access allows local file access from additional allowed directories (cwd and --allowed-paths)
-ap, -allowed-paths string[] additional filesystem roots allowed with -lfa (repeatable)
-no-sandbox disable the OS-level filesystem sandbox and the code template sandbox
-lna, -restrict-local-network-access blocks connections to the local / private network
-i, -interface string network interface to use for network scan
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
@@ -318,10 +320,7 @@ OPTIMIZATIONS:
-stream stream mode - start elaborating without sorting the input
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-response-cache reuse identical safe GET/HEAD responses within a scan (lossless: skips requests with auth, cookies, body or custom headers)
-nh, -no-httpx disable httpx probing for non-url input
-stp, -strict-probe skip templates whose target service is unreachable: HTTP/headless on hosts httpx could not confirm and network templates on closed ports (lossless, no raw-input fallback)
-tf, -tech-filter opt-in: fingerprint HTTP targets and skip product/macro-tagged templates that cannot match (off by default; enables scan-scoped GET cache; fail-open; no-op if no tech-bound templates)
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
+3 -4
View File
@@ -261,7 +261,9 @@ CONFIGURATIONS:
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
-sni string tls sni hostname to use (default: input domain name)
-dka, -dialer-keep-alive value keep-alive duration for network requests.
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
-lfa, -allow-local-file-access allows local file access from additional allowed directories (cwd and --allowed-paths)
-ap, -allowed-paths string[] additional filesystem roots allowed with -lfa (repeatable)
-no-sandbox disable the OS-level filesystem sandbox and the code template sandbox
-lna, -restrict-local-network-access blocks connections to the local / private network
-i, -interface string network interface to use for network scan
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
@@ -338,10 +340,7 @@ OPTIMIZATIONS:
-stream stream mode - start elaborating without sorting the input
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-response-cache reuse identical safe GET/HEAD responses within a scan (lossless: skips requests with auth, cookies, body or custom headers)
-nh, -no-httpx disable httpx probing for non-url input
-stp, -strict-probe skip templates whose target service is unreachable: HTTP/headless on hosts httpx could not confirm and network templates on closed ports (lossless, no raw-input fallback)
-tf, -tech-filter opt-in: fingerprint HTTP targets and skip product/macro-tagged templates that cannot match (off by default; enables scan-scoped GET cache; fail-open; no-op if no tech-bound templates)
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
+3 -4
View File
@@ -244,7 +244,9 @@ CONFIGURATIONS:
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
-sni string tls sni hostname to use (default: input domain name)
-dka, -dialer-keep-alive value keep-alive duration for network requests.
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
-lfa, -allow-local-file-access allows local file access from additional allowed directories (cwd and --allowed-paths)
-ap, -allowed-paths string[] additional filesystem roots allowed with -lfa (repeatable)
-no-sandbox disable the OS-level filesystem sandbox and the code template sandbox
-lna, -restrict-local-network-access blocks connections to the local / private network
-i, -interface string network interface to use for network scan
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
@@ -318,10 +320,7 @@ OPTIMIZATIONS:
-stream stream mode - start elaborating without sorting the input
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-response-cache reuse identical safe GET/HEAD responses within a scan (lossless: skips requests with auth, cookies, body or custom headers)
-nh, -no-httpx disable httpx probing for non-url input
-stp, -strict-probe skip templates whose target service is unreachable: HTTP/headless on hosts httpx could not confirm and network templates on closed ports (lossless, no raw-input fallback)
-tf, -tech-filter opt-in: fingerprint HTTP targets and skip product/macro-tagged templates that cannot match (off by default; enables scan-scoped GET cache; fail-open; no-op if no tech-bound templates)
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
+3 -4
View File
@@ -244,7 +244,9 @@ CONFIGURATIONS:
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
-sni string tls sni hostname to use (default: input domain name)
-dka, -dialer-keep-alive value keep-alive duration for network requests.
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
-lfa, -allow-local-file-access allows local file access from additional allowed directories (cwd and --allowed-paths)
-ap, -allowed-paths string[] additional filesystem roots allowed with -lfa (repeatable)
-no-sandbox disable the OS-level filesystem sandbox and the code template sandbox
-lna, -restrict-local-network-access blocks connections to the local / private network
-i, -interface string network interface to use for network scan
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
@@ -318,10 +320,7 @@ OPTIMIZATIONS:
-stream stream mode - start elaborating without sorting the input
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-response-cache reuse identical safe GET/HEAD responses within a scan (lossless: skips requests with auth, cookies, body or custom headers)
-nh, -no-httpx disable httpx probing for non-url input
-stp, -strict-probe skip templates whose target service is unreachable: HTTP/headless on hosts httpx could not confirm and network templates on closed ports (lossless, no raw-input fallback)
-tf, -tech-filter opt-in: fingerprint HTTP targets and skip product/macro-tagged templates that cannot match (off by default; enables scan-scoped GET cache; fail-open; no-op if no tech-bound templates)
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
+3 -4
View File
@@ -244,7 +244,9 @@ CONFIGURATIONS:
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
-sni string tls sni hostname to use (default: input domain name)
-dka, -dialer-keep-alive value keep-alive duration for network requests.
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
-lfa, -allow-local-file-access allows local file access from additional allowed directories (cwd and --allowed-paths)
-ap, -allowed-paths string[] additional filesystem roots allowed with -lfa (repeatable)
-no-sandbox disable the OS-level filesystem sandbox and the code template sandbox
-lna, -restrict-local-network-access blocks connections to the local / private network
-i, -interface string network interface to use for network scan
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
@@ -318,10 +320,7 @@ OPTIMIZATIONS:
-stream stream mode - start elaborating without sorting the input
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-response-cache reuse identical safe GET/HEAD responses within a scan (lossless: skips requests with auth, cookies, body or custom headers)
-nh, -no-httpx disable httpx probing for non-url input
-stp, -strict-probe skip templates whose target service is unreachable: HTTP/headless on hosts httpx could not confirm and network templates on closed ports (lossless, no raw-input fallback)
-tf, -tech-filter opt-in: fingerprint HTTP targets and skip product/macro-tagged templates that cannot match (off by default; enables scan-scoped GET cache; fail-open; no-op if no tech-bound templates)
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
+3 -4
View File
@@ -244,7 +244,9 @@ CONFIGURATIONS:
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
-sni string tls sni hostname to use (default: input domain name)
-dka, -dialer-keep-alive value keep-alive duration for network requests.
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
-lfa, -allow-local-file-access allows local file access from additional allowed directories (cwd and --allowed-paths)
-ap, -allowed-paths string[] additional filesystem roots allowed with -lfa (repeatable)
-no-sandbox disable the OS-level filesystem sandbox and the code template sandbox
-lna, -restrict-local-network-access blocks connections to the local / private network
-i, -interface string network interface to use for network scan
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
@@ -318,10 +320,7 @@ OPTIMIZATIONS:
-stream stream mode - start elaborating without sorting the input
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-response-cache reuse identical safe GET/HEAD responses within a scan (lossless: skips requests with auth, cookies, body or custom headers)
-nh, -no-httpx disable httpx probing for non-url input
-stp, -strict-probe skip templates whose target service is unreachable: HTTP/headless on hosts httpx could not confirm and network templates on closed ports (lossless, no raw-input fallback)
-tf, -tech-filter opt-in: fingerprint HTTP targets and skip product/macro-tagged templates that cannot match (off by default; enables scan-scoped GET cache; fail-open; no-op if no tech-bound templates)
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
+3 -4
View File
@@ -244,7 +244,9 @@ CONFIGURATIONS:
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
-sni string tls sni hostname to use (default: input domain name)
-dka, -dialer-keep-alive value keep-alive duration for network requests.
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
-lfa, -allow-local-file-access allows local file access from additional allowed directories (cwd and --allowed-paths)
-ap, -allowed-paths string[] additional filesystem roots allowed with -lfa (repeatable)
-no-sandbox disable the OS-level filesystem sandbox and the code template sandbox
-lna, -restrict-local-network-access blocks connections to the local / private network
-i, -interface string network interface to use for network scan
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
@@ -318,10 +320,7 @@ OPTIMIZATIONS:
-stream stream mode - start elaborating without sorting the input
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-response-cache reuse identical safe GET/HEAD responses within a scan (lossless: skips requests with auth, cookies, body or custom headers)
-nh, -no-httpx disable httpx probing for non-url input
-stp, -strict-probe skip templates whose target service is unreachable: HTTP/headless on hosts httpx could not confirm and network templates on closed ports (lossless, no raw-input fallback)
-tf, -tech-filter opt-in: fingerprint HTTP targets and skip product/macro-tagged templates that cannot match (off by default; enables scan-scoped GET cache; fail-open; no-op if no tech-bound templates)
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
+8 -4
View File
@@ -381,7 +381,9 @@ on extensive configurability, massive extensibility and ease of use.`)
flagSet.BoolVar(&options.ZTLS, "ztls", false, "use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default"), //nolint:all
flagSet.StringVar(&options.SNI, "sni", "", "tls sni hostname to use (default: input domain name)"),
flagSet.DurationVarP(&options.DialerKeepAlive, "dialer-keep-alive", "dka", 0, "keep-alive duration for network requests."),
flagSet.BoolVarP(&options.AllowLocalFileAccess, "allow-local-file-access", "lfa", false, "allow all templates in the run to read any file on the system (not just payloads)"),
flagSet.BoolVarP(&options.AllowLocalFileAccess, "allow-local-file-access", "lfa", false, "allows local file access from additional allowed directories (cwd and --allowed-paths)"),
flagSet.StringSliceVarP(&options.AllowedPaths, "allowed-paths", "ap", nil, "additional filesystem roots allowed with -lfa (repeatable)", goflags.CommaSeparatedStringSliceOptions),
flagSet.BoolVar(&options.DisableSandbox, "no-sandbox", false, "disable the OS-level filesystem sandbox and the code template sandbox"),
flagSet.BoolVarP(&options.RestrictLocalNetworkAccess, "restrict-local-network-access", "lna", false, "blocks connections to the local / private network"),
flagSet.StringVarP(&options.Interface, "interface", "i", "", "network interface to use for network scan"),
flagSet.StringVarP(&options.AttackType, "attack-type", "at", "", "type of payload combinations to perform (batteringram,pitchfork,clusterbomb)"),
@@ -466,10 +468,7 @@ on extensive configurability, massive extensibility and ease of use.`)
scanstrategy.TemplateSpray.String(): goflags.EnumVariable(2),
}),
flagSet.DurationVarP(&options.InputReadTimeout, "input-read-timeout", "irt", time.Duration(3*time.Minute), "timeout on input read"),
flagSet.BoolVar(&options.ResponseCache, "response-cache", false, "reuse identical safe GET/HEAD responses within a scan (lossless: skips requests with auth, cookies, body or custom headers)"),
flagSet.BoolVarP(&options.DisableHTTPProbe, "no-httpx", "nh", false, "disable httpx probing for non-url input"),
flagSet.BoolVarP(&options.StrictProbe, "strict-probe", "stp", false, "skip templates whose target service is unreachable: HTTP/headless on hosts httpx could not confirm and network templates on closed ports (lossless, no raw-input fallback)"),
flagSet.BoolVarP(&options.TechFilter, "tech-filter", "tf", false, "opt-in: fingerprint HTTP targets and skip product/macro-tagged templates that cannot match (off by default; enables scan-scoped GET cache; fail-open; no-op if no tech-bound templates)"),
flagSet.BoolVar(&options.PreflightPortScan, "preflight-portscan", false, "run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)"),
flagSet.BoolVar(&options.DisableStdin, "no-stdin", false, "disable stdin processing"),
flagSet.BoolVar(&options.EnableHTTPCache, "http-cache", false, "enable HTTP cache (RFC 9111) for HTTP requests"),
@@ -590,6 +589,11 @@ Additional documentation is available at: https://docs.nuclei.sh/getting-started
resetCallback()
}
// The CLI owns its process, so it applies the process-wide OS filesystem
// sandbox by default. -no-sandbox (DisableSandbox) still turns it off. SDK
// embedders do not get it unless they ask for it.
options.EnableProcessSandbox = true
// when fuzz flag is enabled, set the dast flag to true
if fuzzFlag {
// backwards compatibility for fuzz flag
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolinit"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
)
const processSandboxChildEnv = "NUCLEI_PROCESS_SANDBOX_CHILD"
// TestCLIProcessSandboxWiring checks that the CLI turns the process-wide
// sandbox on by default and that -no-sandbox turns it off. readConfig parses
// os.Args and touches config dirs, so it runs in an isolated child process.
func TestCLIProcessSandboxWiring(t *testing.T) {
if os.Getenv(processSandboxChildEnv) == "1" {
options = &types.Options{Logger: gologger.DefaultLogger}
os.Args = append([]string{os.Args[0], "-disable-update-check"}, strings.Fields(os.Getenv("NUCLEI_TEST_ARGS"))...)
readConfig()
fmt.Printf("process-sandbox=%v\n", protocolinit.ProcessSandboxEnabled(options))
os.Exit(0)
}
tests := []struct {
args string
want string
}{
{args: "", want: "process-sandbox=true"},
{args: "-no-sandbox", want: "process-sandbox=false"},
}
for _, test := range tests {
t.Run("args="+test.args, func(t *testing.T) {
root := t.TempDir()
tempRoot := filepath.Join(root, "tmp")
if err := os.Mkdir(tempRoot, 0o700); err != nil {
t.Fatalf("create temporary root: %v", err)
}
cmd := exec.Command(os.Args[0], "-test.run=^TestCLIProcessSandboxWiring$", "-test.count=1")
cmd.Env = isolatedNucleiStorageEnv(root, tempRoot,
processSandboxChildEnv+"=1",
"NUCLEI_TEST_ARGS="+test.args,
)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("child failed: %v\n%s", err, output)
}
if !strings.Contains(string(output), test.want) {
t.Fatalf("expected %q in child output:\n%s", test.want, output)
}
})
}
}
+3 -1
View File
@@ -61,6 +61,7 @@ require (
github.com/jhump/protoreflect v1.17.0
github.com/julienschmidt/httprouter v1.3.0
github.com/kitabisa/go-ci v1.0.3
github.com/landlock-lsm/go-landlock v0.9.0
github.com/leslie-qiwa/flat v0.0.0-20230424180412-f9d1cf014baa
github.com/lib/pq v1.12.3
github.com/logrusorgru/aurora/v4 v4.0.0
@@ -86,7 +87,7 @@ require (
github.com/projectdiscovery/gologger v1.1.73
github.com/projectdiscovery/gostruct v0.0.2
github.com/projectdiscovery/govaluate v0.0.0-20260615100919-5ee2581bbf7e
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76
github.com/projectdiscovery/gozero v0.1.1-0.20261026122302-786e195b6314
github.com/projectdiscovery/hmap v0.0.102
github.com/projectdiscovery/httpx v1.12.0
github.com/projectdiscovery/interactsh v1.4.1
@@ -467,6 +468,7 @@ require (
gopkg.in/corvus-ch/zbase32.v1 v1.0.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect
mellium.im/sasl v0.3.2 // indirect
software.sslmate.com/src/go-pkcs12 v0.7.2 // indirect
)
+6 -2
View File
@@ -773,6 +773,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/landlock-lsm/go-landlock v0.9.0 h1:2q8G8yx9Hsd5bV+R6PJfgQl0zszNxC8KO+SIqGwfxlw=
github.com/landlock-lsm/go-landlock v0.9.0/go.mod h1:mn5GSi81Jf7yMs5WSi+SUi4sUeNLUGVdbT4Id6wXNQw=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
@@ -959,8 +961,8 @@ github.com/projectdiscovery/gostruct v0.0.2 h1:s8gP8ApugGM4go1pA+sVlPDXaWqNP5BBD
github.com/projectdiscovery/gostruct v0.0.2/go.mod h1:H86peL4HKwMXcQQtEa6lmC8FuD9XFt6gkNR0B/Mu5PE=
github.com/projectdiscovery/govaluate v0.0.0-20260615100919-5ee2581bbf7e h1:vxzgQlz2Cy/YvizYDQx9OhucBcmBotfDhbQ4yCY2vfA=
github.com/projectdiscovery/govaluate v0.0.0-20260615100919-5ee2581bbf7e/go.mod h1:xH7bPwHxUlz1yx9UlVeTF+UVCUaKhTnZgaxHb5z362E=
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76 h1:AN70bbi6BBs7KpIM9w0LxygUN7uzT/oH+owDIQ+Fz/k=
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76/go.mod h1:cWHYnRXoYWHtTpOYyAp5laGYX8GH8ITUhgQaP8G/8FA=
github.com/projectdiscovery/gozero v0.1.1-0.20261026122302-786e195b6314 h1:LxsDXTuF19qS90pVc1/iLY0BNL9ZEyZjBU6g/K0RB/M=
github.com/projectdiscovery/gozero v0.1.1-0.20261026122302-786e195b6314/go.mod h1:RFHYWR70/YBV74h/0P2l0Crht6lHq8zhJfAluVg97OI=
github.com/projectdiscovery/hmap v0.0.102 h1:ybPuf7UQvuOLiHLOUbVMvd+PSlItQU5iQpP1Erl8kaU=
github.com/projectdiscovery/hmap v0.0.102/go.mod h1:IZ92wXFmxYpdn0euZ7VFmypzOBNDwq8c3yRyIIol6XE=
github.com/projectdiscovery/httpx v1.12.0 h1:NN5iDrw7NjPBDlACdjac4lePTQmZ5aOmgOzptMpP44c=
@@ -1743,6 +1745,8 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 h1:Z06sMOzc0GNCwp6efaVrIrz4ywGJ1v+DP0pjVkOfDuA=
kernel.org/pub/linux/libs/security/libcap/psx v1.2.77/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24=
mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0=
mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY=
moul.io/http2curl v1.0.0 h1:6XwpyZOYsgZJrU8exnG87ncVkU1FVCcTRpwzOkTDUi8=
-445
View File
@@ -1,445 +0,0 @@
package runner
import (
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/projectdiscovery/httpx/common/httpx"
"github.com/projectdiscovery/nuclei/v3/pkg/input"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/projectdiscovery/nuclei/v3/pkg/utils"
syncutil "github.com/projectdiscovery/utils/sync"
)
const hostGroupProbeWorkers = 100
// hostGroup is a set of targets that share the same open-port / HTTP signature.
// Used for savings estimates / logging; execution itself is a single spray pass
// with a per-(template,target) filter derived from the same probe data.
type hostGroup struct {
key string
hosts []*contextargs.MetaInput
openPorts map[string]struct{}
httpOK bool
explicitPort string // operator host:port; network dials this, not template defaults
}
type hostProbeResult struct {
input *contextargs.MetaInput
openPorts []string
httpOK bool
explicitPort string
}
// hostReachabilityIndex maps each input to its probed reachability so the
// engine can skip impossible template×target pairs in a single Execute pass.
type hostReachabilityIndex struct {
byInput map[string]hostReachability
}
type hostReachability struct {
openPorts map[string]struct{}
httpOK bool
// explicitPort is the operator-specified port from host:port input (if any).
// Network execution keeps this port (UseNetworkPort does not replace bare
// host:port), so reachability must not require the template's default port.
explicitPort string
}
// Allow reports whether template may produce a finding on input (lossless).
func (idx *hostReachabilityIndex) Allow(t *templates.Template, mi *contextargs.MetaInput) bool {
if idx == nil || t == nil || mi == nil {
return true
}
h, ok := idx.byInput[mi.Input]
if !ok {
return true
}
return templateAllowedOnHost(t, h.httpOK, h.openPorts, h.explicitPort)
}
func templateAllowedOnHost(t *templates.Template, httpOK bool, openPorts map[string]struct{}, explicitPort string) bool {
if t.SelfContained || len(t.Workflows) > 0 || isUniversalTemplate(t) {
return true
}
if isWebTemplate(t) {
return httpOK
}
ports, ok := tcpNetworkOnlyPorts(t)
if !ok {
return true
}
// host:port inputs: dial the operator-chosen port at execution time, not the
// template default (see contextargs.UseNetworkPort). If that port is
// reachable, network templates may run against it.
if explicitPort != "" {
if _, open := openPorts[explicitPort]; open {
return true
}
return false
}
for _, p := range ports {
if _, open := openPorts[p]; open {
return true
}
}
return false
}
// estimateTemplateRequests mirrors core.getRequestCount for scheduling math.
func estimateTemplateRequests(tpls []*templates.Template) int {
count := 0
for _, t := range tpls {
if len(t.Workflows) > 0 {
continue
}
if t.TotalRequests > 0 {
count += t.TotalRequests
continue
}
count++
}
return count
}
func hostGroupKey(openPorts []string, httpOK bool) string {
ports := append([]string(nil), openPorts...)
sort.Strings(ports)
http := "nohttp"
if httpOK {
http = "http"
}
if len(ports) == 0 {
return http + "|-"
}
return http + "|" + strings.Join(ports, ",")
}
func isWebTemplate(t *templates.Template) bool {
return len(t.RequestsHTTP)+len(t.RequestsWithHTTP)+len(t.RequestsHeadless)+len(t.RequestsWebsocket) > 0 &&
len(t.RequestsNetwork)+len(t.RequestsWithTCP)+len(t.RequestsDNS)+len(t.RequestsFile)+
len(t.RequestsSSL)+len(t.RequestsCode)+len(t.RequestsJavascript)+len(t.Workflows) == 0
}
func isUniversalTemplate(t *templates.Template) bool {
if t.SelfContained || len(t.Workflows) > 0 {
return true
}
if _, ok := tcpNetworkOnlyPorts(t); ok {
return false
}
if isWebTemplate(t) {
return false
}
return true
}
// templatesForHostGroup returns templates that can produce findings on this group.
func templatesForHostGroup(all []*templates.Template, g hostGroup) []*templates.Template {
out := make([]*templates.Template, 0, len(all))
for _, t := range all {
if t.SelfContained || len(t.Workflows) > 0 || isUniversalTemplate(t) {
continue
}
if templateAllowedOnHost(t, g.httpOK, g.openPorts, g.explicitPort) {
out = append(out, t)
}
}
return out
}
func partitionTemplates(all []*templates.Template) (web, network, universal, selfContained []*templates.Template) {
for _, t := range all {
switch {
case t.SelfContained:
selfContained = append(selfContained, t)
case len(t.Workflows) > 0:
universal = append(universal, t)
case isWebTemplate(t):
web = append(web, t)
case isUniversalTemplate(t):
universal = append(universal, t)
default:
network = append(network, t)
}
}
return
}
// buildHostReachability probes each target once, then returns an index for
// single-pass filtering plus host groups for logging/estimates.
//
// HTTP reachability prefers the already-populated InputHelper.InputsHTTP map
// (from initializeTemplatesHTTPInput) so we do not re-run httpx.
// When probePorts is false, only explicit input ports are recorded (HTTP-only mode).
func (r *Runner) buildHostReachability(tpls []*templates.Template, httpHelper *input.Helper, probePorts bool) (*hostReachabilityIndex, []hostGroup, error) {
if r.inputProvider == nil {
return nil, nil, fmt.Errorf("no input provider")
}
var portsToProbe []string
if probePorts {
// Only concrete network-template ports. Do not pull 80/443 from HTTP
// templates via portsPopularityFromTemplates — httpOK already comes from
// scheme / InputsHTTP, and spraying web ports across every host inflates
// probe cost and flakes under parallel dial load.
seen := map[string]struct{}{}
for _, t := range tpls {
ps, ok := tcpNetworkOnlyPorts(t)
if !ok {
continue
}
for _, p := range ps {
if _, dup := seen[p]; dup {
continue
}
seen[p] = struct{}{}
portsToProbe = append(portsToProbe, p)
}
}
sort.Strings(portsToProbe)
}
var targets []*contextargs.MetaInput
r.inputProvider.Iterate(func(mi *contextargs.MetaInput) bool {
targets = append(targets, mi.Clone())
return true
})
if len(targets) == 0 {
return &hostReachabilityIndex{byInput: map[string]hostReachability{}}, nil, nil
}
// Only build an httpx client if we still need live HTTP probes.
needLiveHTTPProbe := false
for _, mi := range targets {
if strings.HasPrefix(mi.Input, "http://") || strings.HasPrefix(mi.Input, "https://") {
continue
}
if httpHelper != nil && httpHelper.InputsHTTPProbed {
continue // already probed (hit or miss) during initializeTemplatesHTTPInput
}
needLiveHTTPProbe = true
break
}
var httpClient *httpx.HTTPX
if needLiveHTTPProbe {
dialers := protocolstate.GetDialersWithId(r.options.ExecutionId)
if dialers != nil {
httpxOptions := httpx.DefaultOptions
if r.options.AliveHttpProxy != "" {
httpxOptions.Proxy = r.options.AliveHttpProxy
} else if r.options.AliveSocksProxy != "" {
httpxOptions.Proxy = r.options.AliveSocksProxy
}
httpxOptions.RetryMax = r.options.Retries
if r.options.Timeout > 0 {
httpxOptions.Timeout = time.Duration(r.options.Timeout) * time.Second
}
httpxOptions.NetworkPolicy = dialers.NetworkPolicy
if c, err := httpx.New(&httpxOptions); err == nil {
httpClient = c
}
}
}
workers := hostGroupProbeWorkers
// Fan out one task per host:port so closed-port timeouts do not serialize per host.
type probeJob struct {
hostIdx int
host string
port string
}
var jobs []probeJob
hostMeta := make([]struct {
mi *contextargs.MetaInput
host string
explicitPort string
}, len(targets))
for i, mi := range targets {
host, explicitPort := hostAndExplicitPort(mi.Target())
if host == "" {
host, explicitPort = hostAndExplicitPort(mi.Input)
}
hostMeta[i].mi = mi
hostMeta[i].host = host
hostMeta[i].explicitPort = explicitPort
if !probePorts {
continue
}
// Always probe the operator-specified port (may differ from template
// defaults). Do not spray unrelated template ports onto host:port inputs.
if explicitPort != "" && isNumericPort(explicitPort) {
jobs = append(jobs, probeJob{hostIdx: i, host: host, port: explicitPort})
continue
}
for _, p := range portsToProbe {
jobs = append(jobs, probeJob{hostIdx: i, host: host, port: p})
}
}
if len(jobs) < workers {
workers = len(jobs)
}
if workers < 1 {
workers = 1
}
openByHost := make([]map[string]struct{}, len(targets))
for i := range openByHost {
openByHost[i] = map[string]struct{}{}
// Only assume explicit ports are open when we are not probing them.
if !probePorts {
if ep := hostMeta[i].explicitPort; ep != "" && isNumericPort(ep) {
openByHost[i][ep] = struct{}{}
}
}
}
if len(jobs) > 0 {
swg, err := syncutil.New(syncutil.WithSize(workers))
if err != nil {
return nil, nil, err
}
var mu sync.Mutex
for _, job := range jobs {
swg.Add()
go func(job probeJob) {
defer swg.Done()
switch r.probe(job.host, job.port) {
case portOpen, portUnknown:
mu.Lock()
openByHost[job.hostIdx][job.port] = struct{}{}
mu.Unlock()
}
}(job)
}
swg.Wait()
}
results := make([]hostProbeResult, len(targets))
for i, meta := range hostMeta {
open := openByHost[i]
ports := make([]string, 0, len(open))
for p := range open {
ports = append(ports, p)
}
sort.Strings(ports)
results[i] = hostProbeResult{
input: meta.mi,
openPorts: ports,
httpOK: resolveHTTPReachability(meta.mi, open, httpHelper, httpClient),
explicitPort: meta.explicitPort,
}
}
idx := &hostReachabilityIndex{byInput: make(map[string]hostReachability, len(results))}
grouped := map[string]*hostGroup{}
order := make([]string, 0)
for _, res := range results {
if res.input == nil {
continue
}
portsSet := make(map[string]struct{}, len(res.openPorts))
for _, p := range res.openPorts {
portsSet[p] = struct{}{}
}
idx.byInput[res.input.Input] = hostReachability{
openPorts: portsSet,
httpOK: res.httpOK,
explicitPort: res.explicitPort,
}
key := hostGroupKey(res.openPorts, res.httpOK)
if res.explicitPort != "" {
// Keep explicit host:port inputs in their own estimate bucket so
// network-template fan-out is not attributed to bare hosts.
key = key + "|ep:" + res.explicitPort
}
g, ok := grouped[key]
if !ok {
g = &hostGroup{
key: key,
openPorts: portsSet,
httpOK: res.httpOK,
explicitPort: res.explicitPort,
}
grouped[key] = g
order = append(order, key)
}
g.hosts = append(g.hosts, res.input)
}
out := make([]hostGroup, 0, len(order))
for _, key := range order {
out = append(out, *grouped[key])
}
return idx, out, nil
}
func resolveHTTPReachability(mi *contextargs.MetaInput, open map[string]struct{}, helper *input.Helper, client *httpx.HTTPX) bool {
if mi == nil {
return false
}
if strings.HasPrefix(mi.Input, "http://") || strings.HasPrefix(mi.Input, "https://") {
return true
}
// Reuse httpx results from initializeTemplatesHTTPInput when available.
if helper != nil && helper.InputsHTTP != nil {
if probed, ok := helper.InputsHTTP.Get(mi.Input); ok && len(probed) > 0 {
return true
}
// Empty/skipped probe map (e.g. MultiFormat) must not force non-HTTP.
if !helper.InputsHTTPProbed {
// fall through to live probe / open ports
} else {
// Map was built for all inputs: absence means not HTTP.
return false
}
}
if client != nil {
return utils.ProbeURL(mi.Input, client) != ""
}
if _, ok := open["80"]; ok {
return true
}
if _, ok := open["443"]; ok {
return true
}
return false
}
// estimateGroupedExecutions returns baseline and filtered template×host counts.
func estimateGroupedExecutions(all []*templates.Template, groups []hostGroup, hostCount int) (baseline, filtered int) {
baseline = estimateTemplateRequests(all) * hostCount
_, _, universal, selfContained := partitionTemplates(all)
once := append([]*templates.Template{}, selfContained...)
once = append(once, universal...)
filtered = estimateTemplateRequests(once) * hostCount
for _, g := range groups {
filtered += estimateTemplateRequests(templatesForHostGroup(all, g)) * len(g.hosts)
}
return baseline, filtered
}
// countReachabilityStats returns concrete network template count and distinct numeric ports to probe.
func countReachabilityStats(tpls []*templates.Template) (concreteNetwork int, portsToProbe int) {
ports := map[string]struct{}{}
for _, t := range tpls {
ps, ok := tcpNetworkOnlyPorts(t)
if !ok {
continue
}
concreteNetwork++
for _, p := range ps {
ports[p] = struct{}{}
}
}
return concreteNetwork, len(ports)
}
-217
View File
@@ -1,217 +0,0 @@
package runner
import (
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/input"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/network"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/stretchr/testify/require"
)
func TestHostGroupKey(t *testing.T) {
// Lexicographic sort: "443" < "80".
require.Equal(t, "http|443,80", hostGroupKey([]string{"443", "80"}, true))
require.Equal(t, "nohttp|22", hostGroupKey([]string{"22"}, false))
require.Equal(t, "nohttp|-", hostGroupKey(nil, false))
}
func TestTemplatesForHostGroup(t *testing.T) {
web := &templates.Template{
ID: "web",
RequestsHTTP: []*http.Request{{}},
}
ftp := &templates.Template{
ID: "ftp",
RequestsNetwork: []*network.Request{{
Port: "21",
}},
}
ssh := &templates.Template{
ID: "ssh",
RequestsNetwork: []*network.Request{{
Port: "22",
}},
}
all := []*templates.Template{web, ftp, ssh}
webGroup := hostGroup{
key: "http|80,443",
httpOK: true,
openPorts: map[string]struct{}{"80": {}, "443": {}},
}
got := templatesForHostGroup(all, webGroup)
require.Len(t, got, 1)
require.Equal(t, "web", got[0].ID)
ftpGroup := hostGroup{
key: "nohttp|21",
httpOK: false,
openPorts: map[string]struct{}{"21": {}},
}
got = templatesForHostGroup(all, ftpGroup)
require.Len(t, got, 1)
require.Equal(t, "ftp", got[0].ID)
sshGroup := hostGroup{
key: "nohttp|22",
httpOK: false,
openPorts: map[string]struct{}{"22": {}},
}
got = templatesForHostGroup(all, sshGroup)
require.Len(t, got, 1)
require.Equal(t, "ssh", got[0].ID)
}
func TestPartitionTemplates(t *testing.T) {
web := &templates.Template{ID: "web", RequestsHTTP: []*http.Request{{}}}
ftp := &templates.Template{ID: "ftp", RequestsNetwork: []*network.Request{{Port: "21"}}}
self := &templates.Template{ID: "self", SelfContained: true}
dynNet := &templates.Template{
ID: "dyn",
RequestsNetwork: []*network.Request{{Port: ""}},
}
w, n, u, s := partitionTemplates([]*templates.Template{web, ftp, self, dynNet})
require.Len(t, w, 1)
require.Equal(t, "web", w[0].ID)
require.Len(t, n, 1)
require.Equal(t, "ftp", n[0].ID)
require.Len(t, u, 1)
require.Equal(t, "dyn", u[0].ID)
require.Len(t, s, 1)
require.Equal(t, "self", s[0].ID)
}
func TestEstimateGroupedVsBaseline(t *testing.T) {
web := &templates.Template{ID: "web", RequestsHTTP: []*http.Request{{}}, TotalRequests: 1}
ftp := &templates.Template{ID: "ftp", RequestsNetwork: []*network.Request{{Port: "21"}}, TotalRequests: 1}
ssh := &templates.Template{ID: "ssh", RequestsNetwork: []*network.Request{{Port: "22"}}, TotalRequests: 1}
all := []*templates.Template{web, ftp, ssh}
groups := []hostGroup{
{key: "http|80", httpOK: true, openPorts: map[string]struct{}{"80": {}}, hosts: metaInputs("h1", "h2")},
{key: "nohttp|21", httpOK: false, openPorts: map[string]struct{}{"21": {}}, hosts: metaInputs("h3")},
{key: "nohttp|22", httpOK: false, openPorts: map[string]struct{}{"22": {}}, hosts: metaInputs("h4")},
}
baseline, filtered := estimateGroupedExecutions(all, groups, 4)
require.Equal(t, 12, baseline)
// web*2 + ftp*1 + ssh*1 = 4
require.Equal(t, 4, filtered)
require.Equal(t, 8, baseline-filtered)
}
func TestHostReachabilityAllow(t *testing.T) {
web := &templates.Template{ID: "web", RequestsHTTP: []*http.Request{{}}}
ftp := &templates.Template{ID: "ftp", RequestsNetwork: []*network.Request{{Port: "21"}}}
redis := &templates.Template{ID: "redis", RequestsNetwork: []*network.Request{{Port: "6379"}}}
idx := &hostReachabilityIndex{byInput: map[string]hostReachability{
"h1": {httpOK: true, openPorts: map[string]struct{}{"80": {}}},
"h2": {httpOK: false, openPorts: map[string]struct{}{"21": {}}},
"127.0.0.1:19637": {httpOK: false, openPorts: map[string]struct{}{"19637": {}}, explicitPort: "19637"},
}}
require.True(t, idx.Allow(web, &contextargs.MetaInput{Input: "h1"}))
require.False(t, idx.Allow(web, &contextargs.MetaInput{Input: "h2"}))
require.False(t, idx.Allow(ftp, &contextargs.MetaInput{Input: "h1"}))
require.True(t, idx.Allow(ftp, &contextargs.MetaInput{Input: "h2"}))
require.True(t, idx.Allow(web, &contextargs.MetaInput{Input: "unknown"})) // lossless
// Explicit host:port must allow network templates even when template default
// port (6379) differs from the operator-specified port (19637).
require.True(t, idx.Allow(redis, &contextargs.MetaInput{Input: "127.0.0.1:19637"}))
require.False(t, idx.Allow(web, &contextargs.MetaInput{Input: "127.0.0.1:19637"}))
}
func TestTemplateAllowedOnHostUniversal(t *testing.T) {
dnsish := &templates.Template{ID: "dns", RequestsDNS: nil} // no concrete network ports
// force universal via empty network dynamic port
dyn := &templates.Template{ID: "dyn", RequestsNetwork: []*network.Request{{Port: ""}}}
require.True(t, templateAllowedOnHost(dyn, false, map[string]struct{}{}, ""))
_ = dnsish
self := &templates.Template{ID: "self", SelfContained: true}
require.True(t, templateAllowedOnHost(self, false, nil, ""))
}
func TestTemplateAllowedOnHostExplicitPort(t *testing.T) {
redis := &templates.Template{ID: "redis", RequestsNetwork: []*network.Request{{Port: "6379"}}}
web := &templates.Template{ID: "web", RequestsHTTP: []*http.Request{{}}}
open := map[string]struct{}{"19637": {}}
require.True(t, templateAllowedOnHost(redis, false, open, "19637"))
require.False(t, templateAllowedOnHost(redis, false, open, "")) // bare host: need 6379
require.False(t, templateAllowedOnHost(web, false, open, "19637"))
// Closed explicit port: openPorts empty => network templates denied.
require.False(t, templateAllowedOnHost(redis, false, map[string]struct{}{}, "19637"))
}
func TestTemplatesForHostGroupExplicitPort(t *testing.T) {
redis := &templates.Template{ID: "redis", RequestsNetwork: []*network.Request{{Port: "6379"}}}
ftp := &templates.Template{ID: "ftp", RequestsNetwork: []*network.Request{{Port: "21"}}}
web := &templates.Template{ID: "web", RequestsHTTP: []*http.Request{{}}}
g := hostGroup{
httpOK: false,
openPorts: map[string]struct{}{"19637": {}},
explicitPort: "19637",
}
got := templatesForHostGroup([]*templates.Template{redis, ftp, web}, g)
require.Len(t, got, 2)
ids := map[string]bool{got[0].ID: true, got[1].ID: true}
require.True(t, ids["redis"] && ids["ftp"])
}
func TestCountReachabilityStats(t *testing.T) {
web := &templates.Template{ID: "web", RequestsHTTP: []*http.Request{{}}}
redis := &templates.Template{ID: "redis", RequestsNetwork: []*network.Request{{Port: "6379"}}}
pg := &templates.Template{ID: "pg", RequestsNetwork: []*network.Request{{Port: "5432,5432"}}}
dyn := &templates.Template{ID: "dyn", RequestsNetwork: []*network.Request{{Port: ""}}}
concrete, ports := countReachabilityStats([]*templates.Template{web, redis, pg, dyn})
require.Equal(t, 2, concrete)
require.Equal(t, 2, ports) // 6379 + 5432
}
func TestResolveHTTPReachability(t *testing.T) {
require.True(t, resolveHTTPReachability(&contextargs.MetaInput{Input: "http://web"}, nil, nil, nil))
require.True(t, resolveHTTPReachability(&contextargs.MetaInput{Input: "https://web"}, nil, nil, nil))
require.False(t, resolveHTTPReachability(&contextargs.MetaInput{Input: "redis1"}, map[string]struct{}{}, nil, nil))
require.True(t, resolveHTTPReachability(&contextargs.MetaInput{Input: "host"}, map[string]struct{}{"80": {}}, nil, nil))
require.True(t, resolveHTTPReachability(&contextargs.MetaInput{Input: "host"}, map[string]struct{}{"443": {}}, nil, nil))
}
func TestResolveHTTPReachabilitySkippedProbeFailOpen(t *testing.T) {
// MultiFormat leaves an empty InputsHTTP map without probing — must not
// force non-HTTP when open ports still suggest HTTP.
helper := &input.Helper{InputsHTTPProbed: false}
require.True(t, resolveHTTPReachability(&contextargs.MetaInput{Input: "host"}, map[string]struct{}{"80": {}}, helper, nil))
}
func TestTemplatesForHostGroupSkipsUniversal(t *testing.T) {
web := &templates.Template{ID: "web", RequestsHTTP: []*http.Request{{}}}
univ := &templates.Template{ID: "univ", RequestsNetwork: []*network.Request{{Port: ""}}}
g := hostGroup{httpOK: true, openPorts: map[string]struct{}{"80": {}}}
got := templatesForHostGroup([]*templates.Template{web, univ}, g)
require.Len(t, got, 1)
require.Equal(t, "web", got[0].ID)
}
func TestEstimateGroupedIncludesUniversalsOnce(t *testing.T) {
web := &templates.Template{ID: "web", RequestsHTTP: []*http.Request{{}}, TotalRequests: 1}
univ := &templates.Template{ID: "univ", RequestsNetwork: []*network.Request{{Port: ""}}, TotalRequests: 1}
groups := []hostGroup{
{httpOK: true, openPorts: map[string]struct{}{"80": {}}, hosts: metaInputs("h1")},
}
baseline, filtered := estimateGroupedExecutions([]*templates.Template{web, univ}, groups, 1)
require.Equal(t, 2, baseline)
// universal counted once for all hosts + web for http group
require.Equal(t, 2, filtered)
}
func metaInputs(hosts ...string) []*contextargs.MetaInput {
out := make([]*contextargs.MetaInput, 0, len(hosts))
for _, h := range hosts {
mi := contextargs.NewMetaInput()
mi.Input = h
out = append(out, mi)
}
return out
}
-104
View File
@@ -1,104 +0,0 @@
package runner
import (
"sync"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
)
// planScope narrows the templates run on each target by the scan plan filters
// (strict-probe reachability, tech filter), on top of the per-target profiles
// in base. It is a protocols.TargetScope, so the engine skips, counts and
// narrows clusters for these pairs the same way it does for profiles.
type planScope struct {
base protocols.TargetScope
allow func(*templates.Template, *contextargs.MetaInput) bool
// byPath maps a template path to its template. A path shared by several
// templates, or empty, is left out, so such templates are never filtered.
byPath map[string]*templates.Template
mu sync.RWMutex
selections map[planKey]*planSelection
}
// planKey identifies one selection: the target and the profile selection it
// narrows. Profile selections are comparable by contract.
type planKey struct {
input string
ip string
base protocols.TemplateSelection
}
// planSelection is the selection of one target.
type planSelection struct {
base protocols.TemplateSelection
scope *planScope
input *contextargs.MetaInput
}
var _ protocols.TargetScope = (*planScope)(nil)
func newPlanScope(base protocols.TargetScope, allow func(*templates.Template, *contextargs.MetaInput) bool, tpls []*templates.Template) *planScope {
byPath := make(map[string]*templates.Template, len(tpls))
shared := make(map[string]struct{})
for _, t := range tpls {
if t == nil || t.Path == "" {
continue
}
if _, ok := byPath[t.Path]; ok {
shared[t.Path] = struct{}{}
continue
}
byPath[t.Path] = t
}
for path := range shared {
delete(byPath, path)
}
return &planScope{
base: base,
allow: allow,
byPath: byPath,
selections: make(map[planKey]*planSelection),
}
}
// For returns the selection for input. It is the same value for the same
// input, so the engine can group targets by selection.
func (s *planScope) For(input *contextargs.MetaInput) protocols.TemplateSelection {
var base protocols.TemplateSelection
if s.base != nil {
base = s.base.For(input)
}
if input == nil {
return base
}
key := planKey{input: input.Input, ip: input.CustomIP, base: base}
s.mu.RLock()
sel, ok := s.selections[key]
s.mu.RUnlock()
if ok {
return sel
}
s.mu.Lock()
defer s.mu.Unlock()
if sel, ok := s.selections[key]; ok {
return sel
}
sel = &planSelection{base: base, scope: s, input: input.Clone()}
s.selections[key] = sel
return sel
}
// Allows reports whether the profiles and the plan filters select the template.
func (p *planSelection) Allows(templatePath string) bool {
if p.base != nil && !p.base.Allows(templatePath) {
return false
}
t, ok := p.scope.byPath[templatePath]
if !ok {
return true
}
return p.scope.allow(t, p.input)
}
-52
View File
@@ -1,52 +0,0 @@
package runner
import (
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/stretchr/testify/require"
)
// profileScope selects by path, like per-target profiles do.
type profileScope struct{ sel *profileSelection }
func (p profileScope) For(*contextargs.MetaInput) protocols.TemplateSelection { return p.sel }
type profileSelection struct{ allowed map[string]bool }
func (p *profileSelection) Allows(path string) bool { return p.allowed[path] }
func TestPlanScope(t *testing.T) {
web := &templates.Template{ID: "web", Path: "/t/web.yaml"}
net := &templates.Template{ID: "net", Path: "/t/net.yaml"}
dupA := &templates.Template{ID: "dup-a", Path: "/t/dup.yaml"}
dupB := &templates.Template{ID: "dup-b", Path: "/t/dup.yaml"}
// the filter drops the network template on host-a only
allow := func(tpl *templates.Template, mi *contextargs.MetaInput) bool {
return mi.Input != "host-a" || tpl.ID == "web"
}
input := func(raw string) *contextargs.MetaInput {
mi := contextargs.NewMetaInput()
mi.Input = raw
return mi
}
a, b := input("host-a"), input("host-b")
scope := newPlanScope(nil, allow, []*templates.Template{web, net, dupA, dupB})
selA := scope.For(a)
require.Same(t, selA, scope.For(input("host-a")), "one selection per target, so the engine can group targets")
require.True(t, web.SelectedBy(selA))
require.False(t, net.SelectedBy(selA))
require.True(t, dupA.SelectedBy(selA), "a path shared by several templates is never filtered")
require.True(t, net.SelectedBy(scope.For(b)))
require.True(t, (&templates.Template{Path: "/t/unknown.yaml"}).SelectedBy(selA), "unknown templates are never filtered")
// The plan narrows the profiles, never widens them.
profiles := profileScope{sel: &profileSelection{allowed: map[string]bool{"/t/net.yaml": true}}}
scope = newPlanScope(profiles, allow, []*templates.Template{web, net})
require.False(t, web.SelectedBy(scope.For(b)), "a template the profile leaves out stays out")
require.True(t, net.SelectedBy(scope.For(b)))
require.False(t, net.SelectedBy(scope.For(a)))
}
-199
View File
@@ -1,199 +0,0 @@
package runner
import (
"context"
"net"
"net/url"
"strconv"
"strings"
"time"
"github.com/projectdiscovery/httpx/common/httpx"
"github.com/projectdiscovery/nuclei/v3/pkg/input/provider"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/network"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/projectdiscovery/nuclei/v3/pkg/utils"
sliceutil "github.com/projectdiscovery/utils/slice"
)
type probeResult int
const (
portClosed probeResult = iota // connection refused / host unreachable
portOpen // connect succeeded
portUnknown // timeout / filtered — treat as possibly open
)
const reachabilityProbeTimeout = 750 * time.Millisecond
// strictProbeEnabled reports whether the lossless reachability prune
// applies to this run. It is limited to standard target inputs; request-shaped
// (DAST / multi-format) inputs are never pruned, because probing a request
// template as a live service is meaningless and could drop coverage.
func (r *Runner) strictProbeEnabled() bool {
return r.options.StrictProbe &&
r.inputProvider != nil &&
r.inputProvider.InputType() != provider.MultiFormatInputProvider
}
// noHTTPServiceReachable reports whether NO target actually speaks HTTP(S).
//
// When it returns true, web templates (HTTP/headless/websocket) can be excluded
// losslessly: a port that does not serve HTTP has no HTTP application, so no
// HTTP template can produce a real finding there. Unlike a bare TCP-open check,
// this catches the open-but-non-HTTP case (e.g. redis on 6379): the port is
// open, but it is not an HTTP service, so HTTP templates are pure waste.
//
// It reuses nuclei's own httpx probe (the same one used for input routing), so
// the determination is consistent with the engine and respects the network
// policy (the probe dials only through the policy-configured httpx client).
// Conservative: an explicit http/https target, any scheme that responds, or the
// inability to probe safely all keep web templates in place.
// Skipped when -no-httpx is set so we do not probe or prune against user intent.
func (r *Runner) noHTTPServiceReachable() bool {
if r.inputProvider == nil || r.options.DisableHTTPProbe {
return false
}
dialers := protocolstate.GetDialersWithId(r.options.ExecutionId)
if dialers == nil {
return false // cannot probe within policy — keep web templates
}
httpxOptions := httpx.DefaultOptions
if r.options.AliveHttpProxy != "" {
httpxOptions.Proxy = r.options.AliveHttpProxy
} else if r.options.AliveSocksProxy != "" {
httpxOptions.Proxy = r.options.AliveSocksProxy
}
httpxOptions.RetryMax = r.options.Retries
if r.options.Timeout > 0 {
httpxOptions.Timeout = time.Duration(r.options.Timeout) * time.Second
}
httpxOptions.NetworkPolicy = dialers.NetworkPolicy
client, err := httpx.New(&httpxOptions)
if err != nil {
return false // cannot probe — keep web templates
}
anyHTTP := false
r.inputProvider.Iterate(func(mi *contextargs.MetaInput) bool {
if strings.HasPrefix(mi.Input, "http://") || strings.HasPrefix(mi.Input, "https://") {
anyHTTP = true
return false
}
if utils.ProbeURL(mi.Input, client) != "" {
anyHTTP = true
return false
}
return true
})
return !anyHTTP
}
// tcpNetworkOnlyPorts returns the concrete numeric TCP ports of a single-protocol
// network template, or ok=false if the template is ineligible for port pruning.
func tcpNetworkOnlyPorts(t *templates.Template) ([]string, bool) {
if len(t.RequestsNetwork)+len(t.RequestsWithTCP) == 0 {
return nil, false
}
// must be network-only (no other protocol request could still be reachable)
otherReqs := len(t.RequestsHTTP) + len(t.RequestsWithHTTP) + len(t.RequestsHeadless) +
len(t.RequestsDNS) + len(t.RequestsFile) + len(t.RequestsSSL) +
len(t.RequestsCode) + len(t.RequestsJavascript) + len(t.Workflows)
if otherReqs > 0 {
return nil, false
}
reqs := make([]*network.Request, 0, len(t.RequestsNetwork)+len(t.RequestsWithTCP))
reqs = append(reqs, t.RequestsNetwork...)
reqs = append(reqs, t.RequestsWithTCP...)
var ports []string
for _, req := range reqs {
for _, addr := range req.Address {
if strings.Contains(strings.ToLower(addr), "udp://") {
return nil, false // UDP: a TCP probe cannot prove unreachability
}
}
pp := splitPorts(req.Port)
if len(pp) == 0 {
return nil, false // empty/dynamic port (uses input port) — keep
}
for _, p := range pp {
if !isNumericPort(p) {
return nil, false // service name or template var — keep
}
ports = append(ports, p)
}
}
if len(ports) == 0 {
return nil, false
}
return sliceutil.Dedupe(ports), true
}
func isNumericPort(p string) bool {
n, err := strconv.Atoi(p)
return err == nil && n > 0 && n < 65536
}
// hostAndExplicitPort splits a target into host and explicit port (port empty if
// the target is a bare host).
func hostAndExplicitPort(input string) (string, string) {
if strings.Contains(input, "://") {
if u, err := url.Parse(input); err == nil && u.Hostname() != "" {
return u.Hostname(), u.Port()
}
}
if host, port, err := net.SplitHostPort(input); err == nil {
return host, port
}
return input, ""
}
// probe classifies reachability of host:port WITHIN the scan's network policy.
// Security first: it never dials a target the policy forbids and never bypasses
// the sandbox — all probing goes through the execution's fastdialer, which
// enforces the same DenyList (‑lna, exclude-targets, private/metadata ranges,
// DNS-rebind protection) as the scan itself.
//
// It reconciles security with losslessness by mapping every uncertain case to
// portUnknown ("keep, don't dial"): a policy-denied host, a missing dialer, or a
// timeout all leave templates in place. Pruning happens only on a definitive,
// policy-permitted, refused connection.
func (r *Runner) probe(host, port string) probeResult {
if !protocolstate.IsHostAllowed(r.options.ExecutionId, host) {
// Policy denies this host: do not dial (security), do not prune (lossless).
return portUnknown
}
dialers := protocolstate.GetDialersWithId(r.options.ExecutionId)
if dialers == nil || dialers.Fastdialer == nil {
// No policy-enforcing dialer available: never fall back to a raw dial.
return portUnknown
}
return classifyDial(dialers.Fastdialer.Dial, net.JoinHostPort(host, port), reachabilityProbeTimeout)
}
type dialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
// classifyDial performs one connect via the supplied (policy-enforcing) dialer.
func classifyDial(dial dialFunc, addr string, timeout time.Duration) probeResult {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
conn, err := dial(ctx, "tcp", addr)
if err == nil {
_ = conn.Close()
return portOpen
}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
return portUnknown
}
msg := strings.ToLower(err.Error())
// Only definitive negatives prune. DNS blips / temporary dialer errors must
// stay unknown so mixed-fleet probing under load remains lossless.
if strings.Contains(msg, "refused") || strings.Contains(msg, "unreachable") ||
strings.Contains(msg, "no route to host") || strings.Contains(msg, "network is unreachable") {
return portClosed
}
return portUnknown
}
-133
View File
@@ -1,133 +0,0 @@
package runner
import (
"context"
"net"
"testing"
"time"
httpproto "github.com/projectdiscovery/nuclei/v3/pkg/protocols/http"
jsproto "github.com/projectdiscovery/nuclei/v3/pkg/protocols/javascript"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/network"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
)
func TestHostAndExplicitPort(t *testing.T) {
cases := []struct{ in, host, port string }{
{"127.0.0.1", "127.0.0.1", ""},
{"127.0.0.1:6379", "127.0.0.1", "6379"},
{"http://example.com:8080", "example.com", "8080"},
{"https://example.com", "example.com", ""},
}
for _, c := range cases {
h, p := hostAndExplicitPort(c.in)
if h != c.host || p != c.port {
t.Errorf("hostAndExplicitPort(%q) = (%q,%q), want (%q,%q)", c.in, h, p, c.host, c.port)
}
}
}
func TestIsNumericPort(t *testing.T) {
for _, p := range []string{"80", "6379", "65535", "1"} {
if !isNumericPort(p) {
t.Errorf("isNumericPort(%q) = false, want true", p)
}
}
for _, p := range []string{"ftp", "{{Port}}", "0", "70000", "", "80a"} {
if isNumericPort(p) {
t.Errorf("isNumericPort(%q) = true, want false", p)
}
}
}
// TestTCPNetworkOnlyPorts locks in the losslessness guards: only single-protocol
// network templates with concrete numeric TCP ports are eligible for pruning.
func TestTCPNetworkOnlyPorts(t *testing.T) {
netTmpl := func(addr []string, port string) *templates.Template {
return &templates.Template{RequestsNetwork: []*network.Request{{Address: addr, Port: port}}}
}
t.Run("concrete tcp port eligible", func(t *testing.T) {
ports, ok := tcpNetworkOnlyPorts(netTmpl([]string{"{{Hostname}}"}, "6379,6380"))
if !ok || len(ports) != 2 {
t.Fatalf("want ok with 2 ports, got ok=%v ports=%v", ok, ports)
}
})
t.Run("tls address still tcp", func(t *testing.T) {
if _, ok := tcpNetworkOnlyPorts(netTmpl([]string{"tls://{{Hostname}}"}, "6379")); !ok {
t.Fatal("tls:// should remain eligible (still TCP)")
}
})
t.Run("udp address rejected", func(t *testing.T) {
if _, ok := tcpNetworkOnlyPorts(netTmpl([]string{"udp://{{Hostname}}"}, "161")); ok {
t.Fatal("udp:// must be ineligible — a TCP probe cannot prove UDP unreachability")
}
})
t.Run("service-name port rejected", func(t *testing.T) {
if _, ok := tcpNetworkOnlyPorts(netTmpl([]string{"{{Hostname}}"}, "ftp")); ok {
t.Fatal("service-name port must be ineligible")
}
})
t.Run("dynamic port rejected", func(t *testing.T) {
if _, ok := tcpNetworkOnlyPorts(netTmpl([]string{"{{Hostname}}"}, "{{Port}}")); ok {
t.Fatal("template-var port must be ineligible")
}
})
t.Run("empty port rejected", func(t *testing.T) {
if _, ok := tcpNetworkOnlyPorts(netTmpl([]string{"{{Hostname}}"}, "")); ok {
t.Fatal("empty port (uses input port) must be ineligible")
}
})
t.Run("mixed protocol rejected", func(t *testing.T) {
mixed := netTmpl([]string{"{{Hostname}}"}, "6379")
mixed.RequestsHTTP = []*httpproto.Request{{}}
if _, ok := tcpNetworkOnlyPorts(mixed); ok {
t.Fatal("template with an HTTP request must be ineligible")
}
})
t.Run("javascript protocol rejected", func(t *testing.T) {
js := &templates.Template{RequestsJavascript: []*jsproto.Request{{}}}
if _, ok := tcpNetworkOnlyPorts(js); ok {
t.Fatal("javascript template must be ineligible (opaque dial protocol)")
}
})
}
func TestClassifyDial(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Skip("cannot listen:", err)
}
defer func() { _ = ln.Close() }()
d := &net.Dialer{}
if got := classifyDial(d.DialContext, ln.Addr().String(), reachabilityProbeTimeout); got != portOpen {
t.Errorf("classifyDial(open) = %v, want portOpen", got)
}
// an unused low port on loopback should refuse quickly
if got := classifyDial(d.DialContext, "127.0.0.1:1", reachabilityProbeTimeout); got != portClosed {
t.Errorf("classifyDial(closed) = %v, want portClosed", got)
}
}
func TestClassifyDialDNSFailureIsUnknown(t *testing.T) {
// NXDOMAIN / resolution failure must not prune (lossless under load).
dial := func(ctx context.Context, network, addr string) (net.Conn, error) {
return nil, &net.OpError{Op: "dial", Net: network, Err: &net.DNSError{Err: "no such host", Name: "no-such-host.invalid", IsNotFound: true}}
}
got := classifyDial(dial, "no-such-host.invalid:6379", reachabilityProbeTimeout)
if got != portUnknown {
t.Fatalf("classifyDial(dns-fail) = %v, want portUnknown", got)
}
}
func TestClassifyDialTimeoutIsUnknown(t *testing.T) {
// A dropped connect runs into the timeout and must not prune.
dial := func(ctx context.Context, network, addr string) (net.Conn, error) {
<-ctx.Done()
return nil, &net.OpError{Op: "dial", Net: network, Err: ctx.Err()}
}
got := classifyDial(dial, "192.0.2.1:65535", 50*time.Millisecond)
if got != portUnknown {
t.Fatalf("classifyDial(timeout) = %v, want portUnknown", got)
}
}
+5 -175
View File
@@ -45,8 +45,6 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/disk"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
"github.com/projectdiscovery/nuclei/v3/pkg/core"
scanplan "github.com/projectdiscovery/nuclei/v3/pkg/core/plan"
"github.com/projectdiscovery/nuclei/v3/pkg/core/techfilter"
"github.com/projectdiscovery/nuclei/v3/pkg/external/customtemplates"
fuzzStats "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/stats"
"github.com/projectdiscovery/nuclei/v3/pkg/input"
@@ -70,18 +68,14 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/headless/engine"
httpProtocol "github.com/projectdiscovery/nuclei/v3/pkg/protocols/http"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httpclientpool"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httprespcache"
"github.com/projectdiscovery/nuclei/v3/pkg/reporting"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
templateTypes "github.com/projectdiscovery/nuclei/v3/pkg/templates/types"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/nuclei/v3/pkg/types/scanstrategy"
"github.com/projectdiscovery/nuclei/v3/pkg/utils"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/stats"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
"github.com/projectdiscovery/retryablehttp-go"
ptrutil "github.com/projectdiscovery/utils/ptr"
stringsutil "github.com/projectdiscovery/utils/strings"
)
var (
@@ -300,6 +294,8 @@ func New(options *types.Options) (*Runner, error) {
return nil, errors.Wrap(err, "could not create temporary directory")
}
runner.tmpDir = tmpDir
// The per-scan temp dir is the only temp location templates may access.
protocolstate.SetTemporaryDirectory(options.ExecutionId, tmpDir)
// Cleanup tmpDir only if initialization fails
// On successful initialization, Close() method will handle cleanup
@@ -543,6 +539,7 @@ func (r *Runner) Close() {
}
if r.tmpDir != "" {
_ = os.RemoveAll(r.tmpDir)
protocolstate.SetTemporaryDirectory(r.options.ExecutionId, "")
}
//this is no-op unless nuclei is built with stats build tag
@@ -695,12 +692,6 @@ func (r *Runner) RunEnumeration() error {
DoNotCache: r.options.DoNotCacheTemplates,
Logger: r.Logger,
}
// Attach scan-scoped HTTP response cache only for opt-in -tf, and only
// before templates compile so request executers share the same pointer.
// Default scans leave this nil (no behavioral change).
if r.options.UsesResponseCache() {
executorOpts.HTTPResponseCache = httprespcache.New()
}
if config.DefaultConfig.IsDebugArgEnabled(config.DebugExportURLPattern) {
// Go StdLib style experimental/debug feature switch
@@ -758,16 +749,6 @@ func (r *Runner) RunEnumeration() error {
// If using input-file flags, only load http fuzzing based templates.
loaderConfig := loader.NewConfig(r.options, r.catalog, executorOpts)
// Lossless reachability prune: if no web port is reachable on any target,
// exclude web-protocol templates at load time — they cannot connect, so
// cannot produce a finding. Reuses the standard protocol-type exclusion on
// the loader config only, so shared options stay unchanged across runs.
if r.strictProbeEnabled() && r.noHTTPServiceReachable() {
loaderConfig.ExcludeProtocols = append(slices.Clone(loaderConfig.ExcludeProtocols),
templateTypes.HTTPProtocol, templateTypes.HeadlessProtocol, templateTypes.WebsocketProtocol)
r.Logger.Info().Msgf("reachability prune: no HTTP service on any target; excluding web-protocol templates (lossless)")
}
profiles := r.targetProfiles()
if profiles != nil {
if r.options.AutomaticScan {
@@ -879,15 +860,6 @@ func (r *Runner) RunEnumeration() error {
return errors.Wrap(err, "could not probe http input")
}
executorOpts.InputHelper.InputsHTTP = inputHelpers
executorOpts.InputHelper.InputsHTTPProbed = r.inputProvider.InputType() != provider.MultiFormatInputProvider
// Under strict-probe, skip web templates per-input for targets that
// probed as non-HTTP (lossless), instead of falling back to raw URL.
executorOpts.InputHelper.StrictProbe = r.strictProbeEnabled()
if r.strictProbeEnabled() {
r.Logger.Info().Msgf("Strict probe enabled: hosts without httpx confirmation are skipped for HTTP/headless templates")
}
} else if r.strictProbeEnabled() && r.options.DisableHTTPProbe {
r.Logger.Warning().Msgf("strict-probe has no effect with -no-httpx (httpx probing is disabled)")
}
inputCount := int(r.inputProvider.Count())
@@ -1062,157 +1034,15 @@ func (r *Runner) executeTemplatesInput(store *loader.Store, engine *core.Engine)
return nil, errors.New("no templates provided for scan")
}
// pass input provider to engine
// TODO: this should be not necessary after r.hmapInputProvider is removed + refactored
if r.inputProvider == nil {
return nil, errors.New("no input provider found")
}
return r.executeTemplatesWithScanPlan(engine, finalTemplates)
}
// executeTemplatesWithScanPlan chooses spray strategy and optionally attaches a
// reachability / tech filter, then runs a single Execute pass. Wall clock must
// match or beat baseline spray; probing is skipped when its cost would dominate.
func (r *Runner) executeTemplatesWithScanPlan(engine *core.Engine, finalTemplates []*templates.Template) (*atomic.Bool, error) {
defer r.attachScanPlan(engine, finalTemplates)()
results := engine.ExecuteScanWithOpts(context.Background(), finalTemplates, r.inputProvider, r.options.DisableClustering)
return results, nil
}
// attachScanPlan applies the scan plan to engine for one scan. It returns a
// func that undoes the per-scan state once the scan is done.
func (r *Runner) attachScanPlan(engine *core.Engine, finalTemplates []*templates.Template) func() {
var undo []func()
cleanup := func() {
for i := len(undo) - 1; i >= 0; i-- {
undo[i]()
}
}
hostCount := int(r.inputProvider.Count())
baselineReqs := estimateTemplateRequests(finalTemplates) * hostCount
concreteNet, portsToProbe := countReachabilityStats(finalTemplates)
techBound := techfilter.CountTechBound(finalTemplates)
planIn := scanplan.Input{
Hosts: hostCount,
Templates: len(finalTemplates),
Requests: baselineReqs,
BulkSize: r.options.BulkSize,
Stream: r.options.Stream,
StrictProbe: r.strictProbeEnabled(),
PortsToProbe: portsToProbe,
ConcreteNetworkTemplates: concreteNet,
TechFilter: r.options.TechFilter,
TechBoundTemplates: techBound,
}
p := scanplan.Decide(planIn)
// Resolve -ss auto before Execute so the engine placeholder is unused.
if stringsutil.EqualFoldAny(r.options.ScanStrategy, scanstrategy.Auto.String(), "") {
r.options.ScanStrategy = p.Strategy
}
var httpHelper *input.Helper
if opts := engine.ExecuterOptions(); opts != nil {
httpHelper = opts.InputHelper
}
var reachFilter func(*templates.Template, *contextargs.MetaInput) bool
var techFilterFn func(*templates.Template, *contextargs.MetaInput) bool
var techIdx *techReachabilityIndex
filteredReqs := baselineReqs
if p.BuildReachability {
idx, groups, err := r.buildHostReachability(finalTemplates, httpHelper, p.ProbePorts)
if err != nil {
r.Logger.Warning().Msgf("could not build host reachability index, continuing unfiltered: %s", err)
} else {
baseline, filtered := estimateGroupedExecutions(finalTemplates, groups, hostCount)
filteredReqs = filtered
saved := baseline - filtered
pct := 0.0
if baseline > 0 {
pct = 100 * float64(saved) / float64(baseline)
}
for _, g := range groups {
r.Logger.Info().Msgf("host-group %s: hosts=%d templates=%d", g.key, len(g.hosts), len(templatesForHostGroup(finalTemplates, g)))
}
r.Logger.Info().Msgf(
"host-groups: groups=%d baseline_exec=%d filtered_exec=%d saved=%d (%.1f%%) mode=single-pass",
len(groups), baseline, filtered, saved, pct,
)
if p.UseReachabilityFilter {
reachFilter = idx.Allow
}
}
}
if p.UseTechFilter {
var err error
var cache *httprespcache.Cache
if opts := engine.ExecuterOptions(); opts != nil {
if opts.HTTPResponseCache == nil {
opts.HTTPResponseCache = httprespcache.New()
}
cache = opts.HTTPResponseCache
}
techIdx, err = r.buildTechReachability(finalTemplates, httpHelper, cache)
if err != nil {
r.Logger.Warning().Msgf("could not build tech filter index, continuing without tech-filter: %s", err)
if cache != nil {
cache.Disable()
}
techIdx = nil
} else {
base, techFiltered := estimateTechFilteredExecutions(finalTemplates, techIdx, hostCount)
// When stacked with reachability, take the more aggressive (lower) estimate
// as a lower bound for progress; actual AND filter may skip more.
if techFiltered < filteredReqs {
filteredReqs = techFiltered
}
saved := base - techFiltered
pct := 0.0
if base > 0 {
pct = 100 * float64(saved) / float64(base)
}
r.Logger.Info().Msgf(
"tech-filter: baseline_exec=%d filtered_exec=%d saved=%d (%.1f%%) bound_templates=%d",
base, techFiltered, saved, pct, techBound,
)
techFilterFn = techIdx.Allow
}
} else if opts := engine.ExecuterOptions(); opts != nil && opts.HTTPResponseCache != nil && !r.options.ResponseCache {
// -tf set but planner skipped (e.g. no tech-bound templates): do not
// alter request paths via an idle response cache. Keep it when the user
// asked for -response-cache explicitly.
opts.HTTPResponseCache.Disable()
}
p.ApplyFiltered(filteredReqs)
if opts := engine.ExecuterOptions(); opts != nil {
// The filters narrow the templates per target through the same
// TargetScope as per-target profiles, on top of them. The engine then
// skips the pairs, counts progress and narrows clusters as it does for
// profiles.
if filter := composeTemplateFilters(reachFilter, techFilterFn); filter != nil {
profiles := opts.TargetScope
opts.TargetScope = newPlanScope(profiles, filter, finalTemplates)
undo = append(undo, func() { opts.TargetScope = profiles })
}
if opts.HTTPResponseCache != nil {
undo = append(undo, func() {
hits, misses, stores := opts.HTTPResponseCache.Stats()
r.Logger.Info().Msgf("http-response-cache: hits=%d misses=%d stores=%d", hits, misses, stores)
opts.HTTPResponseCache = nil
})
}
}
r.Logger.Info().Msgf("scan-plan: strategy=%s reach=%v tech=%v expected_req=%d reason=%s",
r.options.ScanStrategy, p.UseReachabilityFilter, p.UseTechFilter, p.ExpectedRequests, p.Reason)
return cleanup
}
// displayExecutionInfo prints parser stats, version info, and scan counts.
func (r *Runner) displayExecutionInfo(store *loader.Store) {
// Display parser stats for templates loaded into the store.
-40
View File
@@ -1,40 +0,0 @@
package runner
import (
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/core"
"github.com/projectdiscovery/nuclei/v3/pkg/input/provider"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httprespcache"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
)
// AttachScanPlan applies -strict-probe and -tech-filter to an SDK engine for
// one scan, the same way the CLI runner does. It returns a func that undoes the
// per-scan state; call it once the scan is done. When neither option is set it
// does nothing, so default SDK scans keep their behavior.
func AttachScanPlan(opts *types.Options, inputProvider provider.InputProvider, logger *gologger.Logger, engine *core.Engine, tpls []*templates.Template) func() {
if opts == nil || engine == nil || inputProvider == nil || (!opts.StrictProbe && !opts.TechFilter) {
return func() {}
}
if logger == nil {
logger = gologger.DefaultLogger
}
// The SDK engine is reused across scans, so keep the options and the
// response cache it owns as they were before this scan.
strategy := opts.ScanStrategy
executerOpts := engine.ExecuterOptions()
var responseCache *httprespcache.Cache
if executerOpts != nil {
responseCache = executerOpts.HTTPResponseCache
}
r := &Runner{options: opts, inputProvider: inputProvider, Logger: logger}
cleanup := r.attachScanPlan(engine, tpls)
return func() {
cleanup()
opts.ScanStrategy = strategy
if executerOpts != nil {
executerOpts.HTTPResponseCache = responseCache
}
}
}
-44
View File
@@ -1,44 +0,0 @@
package runner
import (
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/core"
"github.com/projectdiscovery/nuclei/v3/pkg/input/provider"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httprespcache"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/stretchr/testify/require"
)
// Without -strict-probe or -tech-filter the SDK hook changes nothing.
func TestAttachScanPlanNoop(t *testing.T) {
opts := types.DefaultOptions()
opts.ScanStrategy = "auto"
engine := core.New(opts)
engine.SetExecuterOptions(&protocols.ExecutorOptions{Options: opts})
ip := provider.NewSimpleInputProviderWithUrls(opts.ExecutionId, "http://127.0.0.1:1")
AttachScanPlan(opts, ip, nil, engine, nil)()
require.Equal(t, "auto", opts.ScanStrategy)
require.Nil(t, engine.ExecuterOptions().TargetScope)
}
// The SDK engine is reused across scans, so the hook must leave its options
// and its response cache as they were before the scan.
func TestAttachScanPlanRestoresEngineState(t *testing.T) {
opts := types.DefaultOptions()
opts.ScanStrategy = "auto"
opts.TechFilter = true
cache := httprespcache.New()
engine := core.New(opts)
engine.SetExecuterOptions(&protocols.ExecutorOptions{Options: opts, HTTPResponseCache: cache})
ip := provider.NewSimpleInputProviderWithUrls(opts.ExecutionId, "http://127.0.0.1:1")
cleanup := AttachScanPlan(opts, ip, nil, engine, nil)
require.NotEqual(t, "auto", opts.ScanStrategy, "the plan resolves the auto strategy for the scan")
cleanup()
require.Equal(t, "auto", opts.ScanStrategy)
require.Same(t, cache, engine.ExecuterOptions().HTTPResponseCache)
require.Nil(t, engine.ExecuterOptions().TargetScope)
}
-283
View File
@@ -1,283 +0,0 @@
package runner
import (
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/core/techfilter"
"github.com/projectdiscovery/nuclei/v3/pkg/input"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
httpProtocol "github.com/projectdiscovery/nuclei/v3/pkg/protocols/http"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httpclientpool"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httprespcache"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/projectdiscovery/retryablehttp-go"
syncutil "github.com/projectdiscovery/utils/sync"
unitutils "github.com/projectdiscovery/utils/unit"
wappalyzer "github.com/projectdiscovery/wappalyzergo"
)
const (
techFilterWorkers = 50
techFilterMaxBody = 1 * unitutils.Mega
techFilterHTTPTimeout = 5 * time.Second
techFilterMaxRedirects = 5
)
// techReachabilityIndex filters template×target pairs by product/macro tags.
type techReachabilityIndex struct {
byInput map[string]techfilter.HostProfile // keyed by normalizeInputKey
// boundTags memoizes template ID -> product tags. It is filled lazily from
// the template execution goroutines, so every access needs the mutex.
tagsMu sync.RWMutex
boundTags map[string][]string
}
func normalizeInputKey(raw string) string {
return strings.TrimRight(strings.TrimSpace(raw), "/")
}
// Allow is fail-open: missing profile or unbound template => true.
func (idx *techReachabilityIndex) Allow(t *templates.Template, mi *contextargs.MetaInput) bool {
if idx == nil || t == nil || mi == nil {
return true
}
profile, ok := idx.lookup(mi.Input)
if !ok {
return true
}
return techfilter.AllowTags(profile, idx.tagsFor(t))
}
func (idx *techReachabilityIndex) tagsFor(t *templates.Template) []string {
if t == nil {
return nil
}
idx.tagsMu.RLock()
tags, ok := idx.boundTags[t.ID]
idx.tagsMu.RUnlock()
if ok {
return tags
}
tags = techfilter.TemplateProductTags(t)
idx.tagsMu.Lock()
defer idx.tagsMu.Unlock()
if idx.boundTags == nil {
idx.boundTags = make(map[string][]string)
}
// Another goroutine may have won the race; keep one slice per template ID.
if existing, ok := idx.boundTags[t.ID]; ok {
return existing
}
idx.boundTags[t.ID] = tags
return tags
}
func (idx *techReachabilityIndex) lookup(raw string) (techfilter.HostProfile, bool) {
if idx == nil || idx.byInput == nil {
return techfilter.HostProfile{}, false
}
if p, ok := idx.byInput[normalizeInputKey(raw)]; ok {
return p, true
}
if p, ok := idx.byInput[raw]; ok {
return p, true
}
return techfilter.HostProfile{}, false
}
// buildTechReachability fingerprints HTTP(S) targets and builds a tag index.
// When cache is non-nil, fingerprint GET responses seed it so matching template
// GETs pay no extra RTT.
func (r *Runner) buildTechReachability(tpls []*templates.Template, httpHelper *input.Helper, cache *httprespcache.Cache) (*techReachabilityIndex, error) {
_ = tpls
wapp, err := wappalyzer.New()
if err != nil {
return nil, err
}
// Redirects must be followed. A site that answers "/" with a 302 to its real
// entry point (WordPress -> /wp-admin/install.php, any app -> /login) returns
// an empty body, so the probe would detect nothing and every template bound
// to the undetected product would be pruned. Same-host only, so the probe
// never leaves the target authority.
httpclient, err := httpclientpool.Get(r.options, &httpclientpool.Configuration{
DisableCookie: true,
ResponseHeaderTimeout: techFilterHTTPTimeout,
RedirectFlow: httpclientpool.FollowSameHostRedirect,
MaxRedirects: techFilterMaxRedirects,
}, "")
if err != nil {
return nil, err
}
idx := &techReachabilityIndex{
byInput: make(map[string]techfilter.HostProfile),
boundTags: make(map[string][]string),
}
var mu sync.Mutex
sg, err := syncutil.New(syncutil.WithSize(techFilterWorkers))
if err != nil {
return nil, err
}
fingerprinted := 0
eligible := 0
r.inputProvider.Iterate(func(mi *contextargs.MetaInput) bool {
if mi == nil || mi.Input == "" {
return true
}
fpURL := fingerprintURLForInput(mi.Input, httpHelper)
if fpURL == "" {
return true
}
eligible++
sg.Add()
go func(inputKey, url string) {
defer sg.Done()
profile := fingerprintTarget(wapp, httpclient, url, cache)
key := normalizeInputKey(inputKey)
mu.Lock()
idx.byInput[key] = profile
if profile.HasTags() {
fingerprinted++
}
mu.Unlock()
}(mi.Input, fpURL)
return true
})
sg.Wait()
gologger.Info().Msgf("tech-filter: fingerprinted=%d/%d hosts with tags (eligible=%d)", fingerprinted, len(idx.byInput), eligible)
return idx, nil
}
// fingerprintURLForInput returns an absolute http(s) URL to fingerprint, or "".
// Bare hosts reuse InputsHTTP probed URLs when available.
func fingerprintURLForInput(raw string, helper *input.Helper) string {
if looksLikeHTTPTarget(raw) {
return raw
}
if helper != nil && helper.InputsHTTP != nil {
if probed, ok := helper.InputsHTTP.Get(raw); ok && len(probed) > 0 {
return string(probed)
}
}
return ""
}
func looksLikeHTTPTarget(raw string) bool {
lower := strings.ToLower(raw)
return strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://")
}
func fingerprintTarget(wapp *wappalyzer.Wappalyze, client *retryablehttp.Client, rawURL string, cache *httprespcache.Cache) techfilter.HostProfile {
// Prefer a trailing-slash form that matches typical {{BaseURL}}/ template URLs.
seedURL := rawURL
if !strings.HasSuffix(seedURL, "/") {
seedURL = seedURL + "/"
}
req, err := retryablehttp.NewRequest(http.MethodGet, seedURL, nil)
if err != nil {
return techfilter.HostProfile{}
}
// Same user agent the templates will send, so the response seeded below is
// reachable from the cache instead of being stored under a key nothing hits.
req.Header.Set("User-Agent", httpProtocol.StableUserAgent())
resp, err := client.Do(req)
if err != nil {
return techfilter.HostProfile{}
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, techFilterMaxBody))
if err != nil {
return techfilter.HostProfile{}
}
if cache != nil {
seedFingerprintResponse(cache, req, resp, body, seedURL)
}
info := wapp.FingerprintWithInfo(resp.Header, body)
products := make(map[string][]string, len(info))
for name, app := range info {
products[name] = append([]string(nil), app.Categories...)
}
return techfilter.ProfileFromFingerprint(products)
}
// seedFingerprintResponse stores the probe response under the URL it actually
// belongs to, so templates can reuse the RTT.
//
// The probe follows redirects, so resp may describe a different URL than the one
// requested. Seeding it under the requested URL would hand every template that
// asks for that URL the redirect target's status, headers and body instead of
// the redirect itself, which silently changes what they match on.
func seedFingerprintResponse(cache *httprespcache.Cache, req *retryablehttp.Request, resp *http.Response, body []byte, seedURL string) {
finalURL := seedURL
if resp != nil && resp.Request != nil && resp.Request.URL != nil {
finalURL = resp.Request.URL.String()
}
if finalURL == seedURL {
cache.SeedHTTP(req.Request, resp, body, techFilterMaxBody)
return
}
finalReq := req.Request.Clone(req.Context())
finalReq.URL = resp.Request.URL
finalReq.Host = ""
cache.SeedHTTP(finalReq, resp, body, techFilterMaxBody)
}
// estimateTechFilteredExecutions counts template×host pairs that survive the tech filter.
func estimateTechFilteredExecutions(tpls []*templates.Template, idx *techReachabilityIndex, hostCount int) (baseline, filtered int) {
baseline = estimateTemplateRequests(tpls) * hostCount
if idx == nil || len(idx.byInput) == 0 {
return baseline, baseline
}
filtered = 0
for _, t := range tpls {
reqs := t.TotalRequests
if reqs <= 0 {
reqs = 1
}
if len(t.Workflows) > 0 {
continue
}
tags := idx.tagsFor(t)
for _, profile := range idx.byInput {
if techfilter.AllowTags(profile, tags) {
filtered += reqs
}
}
missing := hostCount - len(idx.byInput)
if missing > 0 {
filtered += reqs * missing
}
}
return baseline, filtered
}
// composeTemplateFilters ANDs optional filters; nil filters are ignored.
func composeTemplateFilters(filters ...func(*templates.Template, *contextargs.MetaInput) bool) func(*templates.Template, *contextargs.MetaInput) bool {
var active []func(*templates.Template, *contextargs.MetaInput) bool
for _, f := range filters {
if f != nil {
active = append(active, f)
}
}
if len(active) == 0 {
return nil
}
return func(t *templates.Template, mi *contextargs.MetaInput) bool {
for _, f := range active {
if !f(t, mi) {
return false
}
}
return true
}
}
-33
View File
@@ -1,33 +0,0 @@
package runner
import (
"sync"
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/core/techfilter"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
)
// tagsFor is filled lazily from the template execution goroutines. Run it under
// -race to catch the concurrent map access that used to crash whole scans.
func TestTechReachabilityIndexConcurrentTagsFor(t *testing.T) {
idx := &techReachabilityIndex{
byInput: map[string]techfilter.HostProfile{},
boundTags: map[string][]string{},
}
var wg sync.WaitGroup
for i := 0; i < 64; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < 64; j++ {
tpl := &templates.Template{ID: string(rune('a' + (i+j)%26))}
_ = idx.tagsFor(tpl)
_ = idx.Allow(tpl, &contextargs.MetaInput{Input: "http://x/"})
}
}(i)
}
wg.Wait()
}
-70
View File
@@ -1,70 +0,0 @@
package runner
import (
"net/http"
"net/url"
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httprespcache"
"github.com/projectdiscovery/retryablehttp-go"
)
func mustReq(t *testing.T, raw string) *retryablehttp.Request {
t.Helper()
req, err := retryablehttp.NewRequest(http.MethodGet, raw, nil)
if err != nil {
t.Fatalf("request: %v", err)
}
return req
}
func respFor(raw string, status int) *http.Response {
u, _ := url.Parse(raw)
return &http.Response{
StatusCode: status,
Status: http.StatusText(status),
Header: http.Header{"Server": []string{"probe"}},
Request: &http.Request{Method: http.MethodGet, URL: u},
}
}
// The probe follows redirects. The body it comes back with belongs to the
// redirect target, so it must never be stored under the requested URL: a
// template asking for "/" must still see the redirect, not the target page.
func TestSeedFingerprintResponseAfterRedirect(t *testing.T) {
const seedURL = "http://example.test/"
const finalURL = "http://example.test/wp-admin/install.php"
c := httprespcache.New()
req := mustReq(t, seedURL)
req.Header.Set("User-Agent", "probe-ua")
seedFingerprintResponse(c, req, respFor(finalURL, 200), []byte("install page"), seedURL)
lookup := req.Request.Clone(req.Context())
if got := c.Get(httprespcache.KeyFromRequest(lookup), "template", lookup); got != nil {
t.Fatal("the redirect target must not be cached under the requested URL")
}
finalLookup := req.Request.Clone(req.Context())
finalLookup.URL, _ = url.Parse(finalURL)
finalLookup.Host = ""
if got := c.Get(httprespcache.KeyFromRequest(finalLookup), "template", finalLookup); got == nil {
t.Fatal("the response must be cached under the URL it belongs to")
}
}
// Without a redirect the response does describe the requested URL, so seeding
// it there is what saves the round trip.
func TestSeedFingerprintResponseWithoutRedirect(t *testing.T) {
const seedURL = "http://example.test/"
c := httprespcache.New()
req := mustReq(t, seedURL)
req.Header.Set("User-Agent", "probe-ua")
seedFingerprintResponse(c, req, respFor(seedURL, 200), []byte("home"), seedURL)
lookup := req.Request.Clone(req.Context())
if got := c.Get(httprespcache.KeyFromRequest(lookup), "template", lookup); got == nil {
t.Fatal("a non-redirected response must be cached under the requested URL")
}
}
-21
View File
@@ -1,21 +0,0 @@
package runner
import (
"testing"
"github.com/projectdiscovery/hmap/store/hybrid"
"github.com/projectdiscovery/nuclei/v3/pkg/input"
"github.com/stretchr/testify/require"
)
func TestFingerprintURLForInput(t *testing.T) {
require.Equal(t, "http://a/", fingerprintURLForInput("http://a/", nil))
require.Equal(t, "", fingerprintURLForInput("example.com", nil))
hm, err := hybrid.New(hybrid.DefaultDiskOptions)
require.NoError(t, err)
t.Cleanup(func() { _ = hm.Close() })
require.NoError(t, hm.Set("example.com", []byte("https://example.com")))
helper := &input.Helper{InputsHTTP: hm, InputsHTTPProbed: true}
require.Equal(t, "https://example.com", fingerprintURLForInput("example.com", helper))
}
+21 -5
View File
@@ -39,8 +39,8 @@ var codeTestCases = []integrationCase{
{Path: "protocols/code/py-interactsh.yaml", TestCase: &codeSnippet{}, DisableOn: isCodeDisabled},
{Path: "protocols/code/ps1-snippet.yaml", TestCase: &codeSnippet{}, DisableOn: func() bool { return !osutils.IsWindows() || isCodeDisabled() }},
{Path: "protocols/code/pre-condition.yaml", TestCase: &codePreCondition{}, DisableOn: isCodeDisabled},
{Path: "protocols/code/sh-virtual.yaml", TestCase: &codeSnippet{}, DisableOn: func() bool { return !osutils.IsLinux() || isCodeDisabled() || !hasAnyExecutable("docker", "podman") }, Serial: true},
{Path: "protocols/code/py-virtual.yaml", TestCase: &codeSnippet{}, DisableOn: func() bool { return !osutils.IsLinux() || isCodeDisabled() || !hasAnyExecutable("docker", "podman") }, Serial: true},
{Path: "protocols/code/sh-virtual.yaml", TestCase: &codeVirtualSnippet{}, DisableOn: func() bool { return !osutils.IsLinux() || isCodeDisabled() || !hasAnyExecutable("docker", "podman") }, Serial: true},
{Path: "protocols/code/py-virtual.yaml", TestCase: &codeVirtualSnippet{}, DisableOn: func() bool { return !osutils.IsLinux() || isCodeDisabled() || !hasAnyExecutable("docker", "podman") }, Serial: true},
{Path: "protocols/code/pwsh-echo.yaml", TestCase: &codeSnippet{}, DisableOn: func() bool { return isCodeDisabled() || !hasAnyExecutable("pwsh", "powershell", "powershell.exe") }},
}
@@ -108,6 +108,12 @@ func ensureSignedIntegrationTemplates() error {
}
templatesToSign = append(templatesToSign, v.Path)
}
for _, v := range securityHardeningTestcases {
if v.DisableOn != nil && v.DisableOn() {
continue
}
templatesToSign = append(templatesToSign, v.Path)
}
for _, templatePath := range templatesToSign {
if err := templates.SignTemplate(tsigner, fixturePath(templatePath)); err != nil {
signedIntegrationTemplatesErr = err
@@ -137,6 +143,16 @@ type codeSnippet struct{}
// Execute executes a test case and returns an error if occurred
func (h *codeSnippet) Execute(filePath string) error {
results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, getEnvValues(), "-t", filePath, "-u", "input", "-code", "-no-sandbox")
if err != nil {
return err
}
return expectResultsCount(results, 1)
}
type codeVirtualSnippet struct{}
func (h *codeVirtualSnippet) Execute(filePath string) error {
results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, getEnvValues(), "-t", filePath, "-u", "input", "-code")
if err != nil {
return err
@@ -148,7 +164,7 @@ type codePreCondition struct{}
// Execute executes a test case and returns an error if occurred
func (h *codePreCondition) Execute(filePath string) error {
results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, getEnvValues(), "-t", filePath, "-u", "input", "-code", "-esc")
results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, getEnvValues(), "-t", filePath, "-u", "input", "-code", "-no-sandbox", "-esc")
if err != nil {
return err
}
@@ -164,7 +180,7 @@ type codeFile struct{}
// Execute executes a test case and returns an error if occurred
func (h *codeFile) Execute(filePath string) error {
results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, getEnvValues(), "-t", filePath, "-u", "input", "-code")
results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, getEnvValues(), "-t", filePath, "-u", "input", "-code", "-no-sandbox")
if err != nil {
return err
}
@@ -175,7 +191,7 @@ type codeEnvVar struct{}
// Execute executes a test case and returns an error if occurred
func (h *codeEnvVar) Execute(filePath string) error {
results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, getEnvValues(), "-t", filePath, "-u", "input", "-V", "baz=baz", "-code")
results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, getEnvValues(), "-t", filePath, "-u", "input", "-V", "baz=baz", "-code", "-no-sandbox")
if err != nil {
return err
}
@@ -113,6 +113,7 @@ func integrationFamilies() []integrationFamily {
{Name: "generic", Cases: genericTestcases},
{Name: "dsl", Cases: dslTestcases},
{Name: "javascript", Cases: jsTestcases},
{Name: "security-hardening", Cases: securityHardeningTestcases},
{Name: "matcher-status", Cases: matcherStatusTestcases},
{Name: "exporters", Cases: exportersTestCases},
{Name: "profile-loader", Cases: profileLoaderTestcases},
@@ -0,0 +1,89 @@
//go:build integration
// +build integration
package integration_test
import (
"os"
"path/filepath"
"runtime"
)
var securityHardeningTestcases = []integrationCase{
{Path: "protocols/javascript/fs-read-deny.yaml", TestCase: &javascriptFSReadDeny{}},
{Path: "protocols/javascript/fs-read-deny-lfa.yaml", TestCase: &javascriptFSReadDenyWithLFA{}},
{Path: "protocols/javascript/net-deny-excluded.yaml", TestCase: &javascriptNetDenyExcluded{}},
{Path: "protocols/javascript/fs-read-allowed-paths.yaml", TestCase: &javascriptFSReadAllowedPaths{}},
}
type javascriptFSReadDeny struct{}
func (j *javascriptFSReadDeny) Execute(filePath string) error {
results, err := runSignedNucleiTemplateAndGetResults(filePath, "127.0.0.1", debug)
if err != nil {
return err
}
return expectResultsCount(results, 1)
}
type javascriptFSReadDenyWithLFA struct{}
func (j *javascriptFSReadDenyWithLFA) Execute(filePath string) error {
denied := "/etc/passwd"
if runtime.GOOS == "windows" {
sys := os.Getenv("SystemRoot")
if sys == "" {
sys = `C:\Windows`
}
denied = filepath.Join(sys, "win.ini")
}
results, err := runSignedNucleiTemplateAndGetResults(
filePath,
"127.0.0.1",
debug,
"-allow-local-file-access",
"-var", "DeniedPath="+denied,
)
if err != nil {
return err
}
return expectResultsCount(results, 1)
}
type javascriptNetDenyExcluded struct{}
func (j *javascriptNetDenyExcluded) Execute(filePath string) error {
results, err := runSignedNucleiTemplateAndGetResults(filePath, "127.0.0.1", debug, "-eh", "203.0.113.10")
if err != nil {
return err
}
return expectResultsCount(results, 1)
}
type javascriptFSReadAllowedPaths struct{}
func (j *javascriptFSReadAllowedPaths) Execute(filePath string) error {
grantedDir, err := os.MkdirTemp("", "nuclei-allowed-paths-*")
if err != nil {
return err
}
defer os.RemoveAll(grantedDir)
secretPath := filepath.Join(grantedDir, "secret.txt")
if err := os.WriteFile(secretPath, []byte("granted-secret"), 0o600); err != nil {
return err
}
results, err := runSignedNucleiTemplateAndGetResults(
filePath,
"127.0.0.1",
debug,
"-allow-local-file-access",
"-ap", grantedDir,
"-var", "ReadPath="+secretPath,
)
if err != nil {
return err
}
return expectResultsCount(results, 1)
}
@@ -10,15 +10,12 @@ info:
code:
- engine:
- sh
- bash
- py
- python3
- python
sandbox:
working-dir: /tmp
image: python:3.14
source: |
#!/usr/bin/env python3
import sys
print("hello from python virtual code")
matchers:
@@ -0,0 +1,34 @@
id: javascript-fs-read-allowed-paths
info:
name: JavaScript FS Read Allowed Paths
author: pdteam
severity: info
description: reads a file granted via --allowed-paths
javascript:
- code: |
const fs = require('nuclei/fs');
let ok = false;
let content = "";
try {
content = fs.ReadFileAsString(ReadPath);
ok = content === "granted-secret";
} catch (e) {
ok = false;
}
Export(to_json({ ok: ok, content: content }));
args:
Host: "{{Host}}"
ReadPath: "{{ReadPath}}"
matchers-condition: and
matchers:
- type: dsl
dsl:
- success == true
- type: word
part: response
condition: and
words:
- '"ok": true'
- "granted-secret"
@@ -0,0 +1,34 @@
id: javascript-fs-read-deny-lfa
info:
name: JavaScript FS Read Deny With LFA
author: pdteam
severity: info
description: confirms -lfa does not bypass absolute paths outside the allowlist
javascript:
- code: |
const fs = require('nuclei/fs');
let ok = true;
let errMsg = "";
try {
fs.ReadFileAsString(DeniedPath);
} catch (e) {
ok = false;
errMsg = String(e);
}
Export(to_json({ ok: ok, err: errMsg }));
args:
Host: "{{Host}}"
DeniedPath: "{{DeniedPath}}"
matchers-condition: and
matchers:
- type: dsl
dsl:
- success == true
- type: word
part: response
condition: and
words:
- '"ok": false'
- "outside allowed directories"
@@ -0,0 +1,31 @@
id: javascript-fs-read-deny
info:
name: JavaScript FS Read Deny
author: pdteam
severity: info
description: attempts to read a file outside the sandbox allowlist
javascript:
- code: |
const fs = require('nuclei/fs');
let ok = true;
let errMsg = "";
try {
fs.ReadFileAsString('/etc/passwd');
} catch (e) {
ok = false;
errMsg = String(e);
}
Export(to_json({ ok: ok, err: errMsg }));
args:
Host: "{{Host}}"
matchers-condition: and
matchers:
- type: dsl
dsl:
- success == true
- type: word
part: response
words:
- '"ok": false'
@@ -0,0 +1,33 @@
id: javascript-net-deny-excluded
info:
name: JavaScript Net Deny Excluded Host
author: pdteam
severity: info
description: attempts to dial a host blocked by network policy
javascript:
- code: |
const net = require('nuclei/net');
let ok = true;
let errMsg = "";
try {
net.Open('tcp', '203.0.113.10:445');
} catch (e) {
ok = false;
errMsg = String(e);
}
Export(to_json({ ok: ok, err: errMsg }));
args:
Host: "{{Host}}"
matchers-condition: and
matchers:
- type: dsl
dsl:
- success == true
- type: word
part: response
condition: and
words:
- '"ok": false'
- "denied address found"
+1 -1
View File
@@ -208,7 +208,7 @@ func (h *workflowCodeKeyValueShare) Execute(filePath string) error {
// provide the Certificate File that the code templates are signed with
certEnvVar := signer.CertEnvVarName + "=" + testCertFile
results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, []string{certEnvVar}, "-workflows", filePath, "-target", "input", "-code")
results, err := testutils.RunNucleiArgsWithEnvAndGetResults(debug, []string{certEnvVar}, "-workflows", filePath, "-target", "input", "-code", "-no-sandbox")
if err != nil {
return err
}
+6
View File
@@ -111,6 +111,12 @@ func WithExtraArgs(args ...string) RunnerOption {
}
}
func WithAllowLocalFileAccess(allow bool) RunnerOption {
return func(r *Runner) {
r.AllowLocalFileAccess = allow
}
}
var defaultRunner = NewRunner()
func DefaultRunner() *Runner {
+12
View File
@@ -73,6 +73,18 @@ For Various use cases like batching etc. you might want to run nuclei in gorouti
defer ne.Close()
```
## Filesystem Sandbox
The nuclei CLI applies an OS-level filesystem sandbox (landlock on Linux) to its whole process. The CLI turns it off with `-no-sandbox`.
The SDK does not apply it by default. Landlock confines the whole process and cannot be undone, so it would also confine your program. To apply it, pass `nuclei.EnableProcessSandbox()`:
```go
ne, err := nuclei.NewNucleiEngineCtx(ctx, nuclei.EnableProcessSandbox())
```
After the engine starts, your process can only use the nuclei roots (templates, config, cache, temp, outputs, the working directory and allowed paths) and the read-only system paths. Use it only when your process does nothing else on the filesystem. The option has no effect on platforms without landlock. Setting `DisableSandbox` in the engine options turns it off.
## More Documentation
For complete documentation of nuclei library, please refer to [godoc](https://pkg.go.dev/github.com/projectdiscovery/nuclei/v3/lib) which contains all available options and methods.
+16 -38
View File
@@ -173,16 +173,6 @@ func WithResponseReadSize(responseReadSize int) NucleiSDKOptions {
}
}
// EnableResponseCache reuses identical safe GET/HEAD responses within one scan.
// It is off by default: a template that depends on state or timing could see a
// different result.
func EnableResponseCache() NucleiSDKOptions {
return func(e *NucleiEngine) error {
e.opts.ResponseCache = true
return nil
}
}
// WithGlobalRateLimit sets global rate (i.e all hosts combined) limit options
// Deprecated: will be removed in favour of WithGlobalRateLimitCtx in next release
func WithGlobalRateLimit(maxTokens int, duration time.Duration) NucleiSDKOptions {
@@ -421,6 +411,22 @@ func WithSandboxOptions(allowLocalFileAccess bool, restrictLocalNetworkAccess bo
}
}
// EnableProcessSandbox applies the OS-level filesystem sandbox (landlock on
// Linux) to the whole host process when the engine initializes, the same way
// the nuclei CLI does by default. It is off for SDK users because the sandbox
// is process-wide and irreversible: after it is applied, the host program can
// only reach the nuclei allowlisted roots (templates, config, cache, temp,
// outputs, cwd and allowed paths) and the read-only runtime paths, for the
// rest of its life. Enable it only when the host process does nothing else
// on the filesystem. It has no effect on platforms without landlock, and
// DisableSandbox in the engine options still turns it off.
func EnableProcessSandbox() NucleiSDKOptions {
return func(e *NucleiEngine) error {
e.opts.EnableProcessSandbox = true
return nil
}
}
// EnableCodeTemplates allows loading/executing code protocol templates
func EnableCodeTemplates() NucleiSDKOptions {
return func(e *NucleiEngine) error {
@@ -766,31 +772,3 @@ func WithTemporaryDirectory(parentDir string) NucleiSDKOptions {
return nil
}
}
// WithPerHostRateLimit applies the rate limit per host instead of globally.
func WithPerHostRateLimit(enabled bool) NucleiSDKOptions {
return func(e *NucleiEngine) error {
e.opts.PerHostRateLimit = enabled
return nil
}
}
// EnableStrictProbe skips templates whose target service is unreachable: web
// templates on hosts that do not speak HTTP and network templates on closed
// ports. It is the SDK form of -strict-probe.
func EnableStrictProbe() NucleiSDKOptions {
return func(e *NucleiEngine) error {
e.opts.StrictProbe = true
return nil
}
}
// EnableTechFilter fingerprints HTTP targets and skips product-bound templates
// that cannot match them. It also turns on the response cache, which the
// fingerprint requests seed. It is the SDK form of -tech-filter.
func EnableTechFilter() NucleiSDKOptions {
return func(e *NucleiEngine) error {
e.opts.TechFilter = true
return nil
}
}
-8
View File
@@ -5,7 +5,6 @@ import (
"time"
"github.com/logrusorgru/aurora/v4"
"github.com/projectdiscovery/nuclei/v3/internal/runner"
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
"github.com/projectdiscovery/nuclei/v3/pkg/core"
@@ -13,7 +12,6 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/loader/workflow"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httprespcache"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/nuclei/v3/pkg/utils"
"github.com/projectdiscovery/utils/errkit"
@@ -62,11 +60,6 @@ func createEphemeralObjects(ctx context.Context, base *NucleiEngine, opts *types
opts.RateLimitDuration = time.Second
}
u.executerOpts.RateLimiter = utils.GetRateLimiter(ctx, opts.RateLimit, opts.RateLimitDuration)
// Each execution gets its own response cache, created before its
// templates compile.
if opts.UsesResponseCache() && u.executerOpts.HTTPResponseCache == nil {
u.executerOpts.HTTPResponseCache = httprespcache.New()
}
return u, nil
}
@@ -247,7 +240,6 @@ func (e *ThreadSafeNucleiEngine) ExecuteNucleiWithOptsCtx(ctx context.Context, t
engine := core.New(tmpEngine.opts)
engine.SetExecuterOptions(unsafeOpts.executerOpts)
defer runner.AttachScanPlan(tmpEngine.opts, inputProvider, tmpEngine.opts.Logger, engine, store.Templates())()
_ = engine.ExecuteScanWithOpts(ctx, store.Templates(), inputProvider, false)
engine.WorkPool().Wait()
+27
View File
@@ -0,0 +1,27 @@
package nuclei
import (
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolinit"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
)
func TestProcessSandboxIsOptInForSDK(t *testing.T) {
e := &NucleiEngine{opts: types.DefaultOptions()}
if protocolinit.ProcessSandboxEnabled(e.opts) {
t.Fatal("SDK default options enable the process-wide sandbox")
}
if err := EnableProcessSandbox()(e); err != nil {
t.Fatalf("EnableProcessSandbox: %v", err)
}
if !protocolinit.ProcessSandboxEnabled(e.opts) {
t.Fatal("EnableProcessSandbox did not enable the process-wide sandbox")
}
e.opts.DisableSandbox = true
if protocolinit.ProcessSandboxEnabled(e.opts) {
t.Fatal("DisableSandbox did not override EnableProcessSandbox")
}
}
-120
View File
@@ -1,120 +0,0 @@
package nuclei_test
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
nuclei "github.com/projectdiscovery/nuclei/v3/lib"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/stretchr/testify/require"
)
func TestScanPlanOptions(t *testing.T) {
ne, err := nuclei.NewNucleiEngineCtx(context.Background(),
nuclei.DisableUpdateCheck(),
nuclei.EnableStrictProbe(),
nuclei.EnableTechFilter(),
nuclei.EnableResponseCache(),
)
require.NoError(t, err)
defer ne.Close()
opts := ne.Options()
require.True(t, opts.StrictProbe)
require.True(t, opts.TechFilter)
require.True(t, opts.ResponseCache)
}
// sdkCacheTemplates writes two templates that both GET /same. The second also
// GETs /other, so request clustering cannot merge them into one request.
func sdkCacheTemplates(t *testing.T) []string {
t.Helper()
dir := t.TempDir()
a := `id: sdk-cache-a
info:
name: SDK Cache A
author: test
severity: info
http:
- method: GET
path:
- "{{BaseURL}}/same"
matchers:
- type: word
words:
- "cached-body"
`
b := `id: sdk-cache-b
info:
name: SDK Cache B
author: test
severity: info
http:
- method: GET
path:
- "{{BaseURL}}/same"
- "{{BaseURL}}/other"
matchers:
- type: word
words:
- "cached-body"
`
pa, pb := filepath.Join(dir, "a.yaml"), filepath.Join(dir, "b.yaml")
require.NoError(t, os.WriteFile(pa, []byte(a), 0o600))
require.NoError(t, os.WriteFile(pb, []byte(b), 0o600))
return []string{pa, pb}
}
// With the SDK options for the scan plan set, both templates still run and
// match, and the response cache sends /same once. Without them it is sent
// twice.
func TestSDKScanPlanAndResponseCache(t *testing.T) {
var sameHits atomic.Int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/same" {
sameHits.Add(1)
time.Sleep(200 * time.Millisecond)
}
_, _ = w.Write([]byte("cached-body"))
}))
defer ts.Close()
tpls := sdkCacheTemplates(t)
run := func(extra ...nuclei.NucleiSDKOptions) map[string]bool {
sameHits.Store(0)
opts := append([]nuclei.NucleiSDKOptions{
nuclei.DisableUpdateCheck(),
nuclei.WithTemplatesOrWorkflows(nuclei.TemplateSources{Templates: tpls}),
}, extra...)
ne, err := nuclei.NewNucleiEngineCtx(context.Background(), opts...)
require.NoError(t, err)
defer ne.Close()
ne.Options().RestrictLocalNetworkAccess = false
ne.Options().Retries = 0
ne.LoadTargets([]string{ts.URL}, false)
var mu sync.Mutex
matched := map[string]bool{}
require.NoError(t, ne.ExecuteCallbackWithCtx(context.Background(), func(event *output.ResultEvent) {
mu.Lock()
defer mu.Unlock()
matched[event.TemplateID] = true
}))
return matched
}
matched := run(nuclei.EnableResponseCache(), nuclei.EnableStrictProbe(), nuclei.EnableTechFilter())
require.Equal(t, map[string]bool{"sdk-cache-a": true, "sdk-cache-b": true}, matched)
require.Equal(t, int32(1), sameHits.Load(), "the response cache must send /same once")
matched = run()
require.Equal(t, map[string]bool{"sdk-cache-a": true, "sdk-cache-b": true}, matched)
require.Equal(t, int32(2), sameHits.Load(), "without the cache /same is sent by each template")
}
+4 -5
View File
@@ -9,7 +9,6 @@ import (
"sync"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/internal/runner"
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog"
metadataindex "github.com/projectdiscovery/nuclei/v3/pkg/catalog/index"
@@ -25,6 +24,7 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/hosterrorscache"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolinit"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/headless/engine"
"github.com/projectdiscovery/nuclei/v3/pkg/reporting"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
@@ -280,6 +280,9 @@ func (e *NucleiEngine) closeInternal() {
}
if e.tmpDir != "" {
_ = os.RemoveAll(e.tmpDir)
if e.opts != nil {
protocolstate.SetTemporaryDirectory(e.opts.ExecutionId, "")
}
}
if e.opts != nil {
generators.ClearOptionsPayloadMap(e.opts)
@@ -332,10 +335,6 @@ func (e *NucleiEngine) ExecuteCallbackWithCtx(ctx context.Context, callback ...f
wg.Add(1)
go func() {
defer wg.Done()
// A response cached by an earlier scan of this engine is never served
// to this one.
e.executerOpts.HTTPResponseCache.Reset()
defer runner.AttachScanPlan(e.opts, e.inputProvider, e.opts.Logger, e.engine, templatesAndWorkflows)()
_ = e.engine.ExecuteScanWithOpts(ctx, templatesAndWorkflows, e.inputProvider, false)
}()
+2 -7
View File
@@ -33,7 +33,6 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/headless/engine"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httpclientpool"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httprespcache"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
nucleiUtils "github.com/projectdiscovery/nuclei/v3/pkg/utils"
@@ -272,6 +271,8 @@ func (e *NucleiEngine) init(ctx context.Context) error {
}
e.tmpDir = tmpDir
}
// The per-scan temp dir is the only temp location templates may access.
protocolstate.SetTemporaryDirectory(e.opts.ExecutionId, e.tmpDir)
e.executerOpts = &protocols.ExecutorOptions{
Output: e.customWriter,
@@ -354,12 +355,6 @@ func (e *NucleiEngine) init(ctx context.Context) error {
e.executerOpts.Options.Parser = op
}
// Templates compile against e.executerOpts, so the response cache must
// exist before they load. Each scan resets it.
if e.opts.UsesResponseCache() && e.executerOpts.HTTPResponseCache == nil {
e.executerOpts.HTTPResponseCache = httprespcache.New()
}
e.engine = core.New(e.opts)
e.engine.SetExecuterOptions(e.executerOpts)
+2 -1
View File
@@ -86,7 +86,8 @@ func (e *Engine) ExecuteScanWithOpts(ctx context.Context, templatesList []*templ
}
if stringsutil.EqualFoldAny(e.options.ScanStrategy, scanstrategy.Auto.String(), "") {
// Last-resort fallback: runner scan planner normally resolves auto.
// TODO: this is only a placeholder, auto scan strategy should choose scan strategy
// based on no of hosts , templates , stream and other optimization parameters
e.options.ScanStrategy = scanstrategy.TemplateSpray.String()
}
-165
View File
@@ -1,165 +0,0 @@
package plan
import (
"fmt"
"github.com/projectdiscovery/nuclei/v3/pkg/types/scanstrategy"
)
// Input is the scan shape the planner uses to pick strategy and reachability.
type Input struct {
Hosts int
Templates int
Requests int // template×host request estimate (baseline)
BulkSize int
Stream bool
StrictProbe bool
// Reachability / probe budget (known before or after probing).
PortsToProbe int
ConcreteNetworkTemplates int
// FilteredRequests, when > 0, is the post-filter request estimate the plan reports.
FilteredRequests int
// GroupCount is distinct host signatures after probing (0 = unknown).
GroupCount int
// TechFilter enables product/macro tag filtering after cheap HTTP fingerprinting.
TechFilter bool
// TechBoundTemplates is how many templates carry product/macro tags.
TechBoundTemplates int
}
// Plan is the execution plan for a single scan pass.
type Plan struct {
Strategy string
BuildReachability bool
ProbePorts bool
UseReachabilityFilter bool
UseTechFilter bool
ExpectedRequests int64
Reason string
}
// Decide chooses spray strategy and whether to spend budget on reachability probing.
//
// Hard bar: never choose work that is expected to regress wall clock vs baseline spray.
// When probe cost would dominate and savings are uncertain, reachability is skipped.
func Decide(in Input) Plan {
p := Plan{
Strategy: chooseStrategy(in),
ExpectedRequests: int64(max(in.Requests, 0)),
}
reachReason := "reachability=off"
if in.StrictProbe {
reach := decideReachability(in)
p.BuildReachability = reach.build
p.ProbePorts = reach.probePorts
p.UseReachabilityFilter = reach.useFilter
reachReason = reach.reason
}
tech := decideTechFilter(in)
p.UseTechFilter = tech.use
p.Reason = strategyReason(in, p.Strategy) + "; " + reachReason + "; " + tech.reason
if in.FilteredRequests > 0 {
p.ExpectedRequests = int64(in.FilteredRequests)
}
return p
}
func chooseStrategy(in Input) string {
if in.Stream || in.Hosts <= 0 || in.Templates <= 0 {
return scanstrategy.TemplateSpray.String()
}
bulk := in.BulkSize
if bulk <= 0 {
bulk = 25
}
// Host-spray only when the shape strongly favors per-host locality.
// Conservative default is template-spray (historical auto) so wall clock
// does not regress on small / mixed benches.
if in.Hosts <= bulk && in.Hosts <= 10 && in.Templates >= 10*in.Hosts && in.Requests >= 5000 {
return scanstrategy.HostSpray.String()
}
return scanstrategy.TemplateSpray.String()
}
func strategyReason(in Input, strategy string) string {
return fmt.Sprintf("strategy=%s hosts=%d templates=%d", strategy, in.Hosts, in.Templates)
}
type reachabilityDecision struct {
build bool
probePorts bool
useFilter bool
reason string
}
func decideReachability(in Input) reachabilityDecision {
if in.Hosts <= 0 || in.Templates <= 0 {
return reachabilityDecision{reason: "reachability=skipped-empty"}
}
probeCost := in.PortsToProbe * in.Hosts
baseline := in.Requests
if baseline <= 0 {
baseline = in.Templates * in.Hosts
}
// No concrete network ports to probe: HTTP-only index is cheap (reuse httpx map).
if in.PortsToProbe == 0 || in.ConcreteNetworkTemplates == 0 {
return reachabilityDecision{
build: true,
probePorts: false,
useFilter: true,
reason: "reachability=http-only",
}
}
// Probe budget: if dialing every port×host costs more than the scan itself
// and we have no signal of a mixed fleet, skip probing to match baseline time.
if probeCost > baseline && in.GroupCount <= 1 {
// GroupCount==0 (unknown) or 1 (homogeneous): do not pay for probes.
if probeCost > max(baseline*2, 500) {
return reachabilityDecision{
reason: fmt.Sprintf("reachability=skipped-probe-budget cost=%d baseline=%d", probeCost, baseline),
}
}
}
// After probing, homogeneous fleets still benefit from a cheap filter (no extra dials
// during spray for closed-port templates that global prune may have kept).
return reachabilityDecision{
build: true,
probePorts: true,
useFilter: true,
reason: fmt.Sprintf("reachability=full probe_cost=%d", probeCost),
}
}
type techFilterDecision struct {
use bool
reason string
}
func decideTechFilter(in Input) techFilterDecision {
// Opt-in only: never enable from strategy/reachability alone.
if !in.TechFilter {
return techFilterDecision{reason: "tech-filter=off"}
}
// Skip fingerprinting when nothing is tech-bound — otherwise we add
// probe latency with zero savings (wall-clock regression).
if in.TechBoundTemplates <= 0 {
return techFilterDecision{reason: "tech-filter=skipped-no-bound-templates"}
}
return techFilterDecision{use: true, reason: fmt.Sprintf("tech-filter=on bound=%d", in.TechBoundTemplates)}
}
// ApplyFiltered updates ExpectedRequests once post-probe estimates are known.
func (p *Plan) ApplyFiltered(filtered int) {
if filtered > 0 {
p.ExpectedRequests = int64(filtered)
}
}
-205
View File
@@ -1,205 +0,0 @@
package plan
import (
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/types/scanstrategy"
"github.com/stretchr/testify/require"
)
func TestDecideStrategyHostSpray(t *testing.T) {
p := Decide(Input{
Hosts: 5,
Templates: 200,
Requests: 10000,
BulkSize: 25,
})
require.Equal(t, scanstrategy.HostSpray.String(), p.Strategy)
require.Contains(t, p.Reason, "strategy=host-spray")
}
func TestDecideStrategyTemplateSpraySmallBench(t *testing.T) {
p := Decide(Input{
Hosts: 5,
Templates: 100,
Requests: 500,
BulkSize: 25,
})
require.Equal(t, scanstrategy.TemplateSpray.String(), p.Strategy)
}
func TestDecideStrategyTemplateSprayManyHosts(t *testing.T) {
p := Decide(Input{
Hosts: 1000,
Templates: 50,
Requests: 50000,
BulkSize: 25,
})
require.Equal(t, scanstrategy.TemplateSpray.String(), p.Strategy)
}
func TestDecideStrategyStreamDefaultsTemplateSpray(t *testing.T) {
p := Decide(Input{
Hosts: 5,
Templates: 100,
Stream: true,
BulkSize: 25,
})
require.Equal(t, scanstrategy.TemplateSpray.String(), p.Strategy)
}
func TestDecideReachabilityOffWithoutStrictProbe(t *testing.T) {
p := Decide(Input{
Hosts: 5,
Templates: 60,
Requests: 300,
StrictProbe: false,
PortsToProbe: 3,
})
require.False(t, p.BuildReachability)
require.False(t, p.UseReachabilityFilter)
require.Contains(t, p.Reason, "reachability=off")
}
func TestDecideReachabilityHTTPOnly(t *testing.T) {
p := Decide(Input{
Hosts: 5,
Templates: 60,
Requests: 300,
StrictProbe: true,
PortsToProbe: 0,
ConcreteNetworkTemplates: 0,
})
require.True(t, p.BuildReachability)
require.False(t, p.ProbePorts)
require.True(t, p.UseReachabilityFilter)
require.Contains(t, p.Reason, "reachability=http-only")
}
func TestDecideReachabilityProbeBudgetSkip(t *testing.T) {
p := Decide(Input{
Hosts: 100,
Templates: 10,
Requests: 1000,
StrictProbe: true,
PortsToProbe: 50, // cost = 5000 > 2*baseline
ConcreteNetworkTemplates: 5,
GroupCount: 0,
})
require.False(t, p.BuildReachability)
require.False(t, p.UseReachabilityFilter)
require.Contains(t, p.Reason, "reachability=skipped-probe-budget")
}
func TestDecideReachabilityProbeWithinBudget(t *testing.T) {
p := Decide(Input{
Hosts: 100,
Templates: 10,
Requests: 1000,
StrictProbe: true,
PortsToProbe: 15, // cost = 1500: above baseline, within 2*baseline
ConcreteNetworkTemplates: 5,
GroupCount: 0,
})
require.True(t, p.BuildReachability)
require.True(t, p.ProbePorts)
require.True(t, p.UseReachabilityFilter)
require.Contains(t, p.Reason, "reachability=full")
}
func TestDecideReachabilityFull(t *testing.T) {
p := Decide(Input{
Hosts: 5,
Templates: 60,
Requests: 300,
StrictProbe: true,
PortsToProbe: 3, // cost = 15 << baseline
ConcreteNetworkTemplates: 30,
})
require.True(t, p.BuildReachability)
require.True(t, p.ProbePorts)
require.True(t, p.UseReachabilityFilter)
require.Contains(t, p.Reason, "reachability=full")
}
func TestDecideReachabilityEmptyInput(t *testing.T) {
p := Decide(Input{StrictProbe: true, Hosts: 0, Templates: 10})
require.False(t, p.BuildReachability)
require.Contains(t, p.Reason, "reachability=skipped-empty")
}
func TestDecideReachabilityConcreteZeroUsesHTTPOnly(t *testing.T) {
p := Decide(Input{
Hosts: 20,
Templates: 100,
Requests: 2000,
StrictProbe: true,
PortsToProbe: 5,
ConcreteNetworkTemplates: 0, // web-only set
})
require.True(t, p.BuildReachability)
require.False(t, p.ProbePorts)
require.True(t, p.UseReachabilityFilter)
}
func TestDecideHostSprayRequiresLargeRequestBudget(t *testing.T) {
// hosts small + many templates but requests below threshold → template-spray
p := Decide(Input{
Hosts: 5,
Templates: 200,
Requests: 4999,
BulkSize: 25,
})
require.Equal(t, scanstrategy.TemplateSpray.String(), p.Strategy)
}
func TestDecideApplyFilteredIgnoredWhenZero(t *testing.T) {
p := Decide(Input{
Hosts: 5,
Templates: 10,
Requests: 50,
StrictProbe: false,
FilteredRequests: 0,
})
require.Equal(t, int64(50), p.ExpectedRequests)
p.ApplyFiltered(0)
require.Equal(t, int64(50), p.ExpectedRequests)
p.ApplyFiltered(12)
require.Equal(t, int64(12), p.ExpectedRequests)
}
func TestDecideTechFilterOffByDefault(t *testing.T) {
p := Decide(Input{
Hosts: 5,
Templates: 100,
Requests: 500,
TechFilter: false,
TechBoundTemplates: 40,
})
require.False(t, p.UseTechFilter)
require.Contains(t, p.Reason, "tech-filter=off")
}
func TestDecideTechFilterSkippedWhenNoBound(t *testing.T) {
p := Decide(Input{
Hosts: 5,
Templates: 100,
Requests: 500,
TechFilter: true,
TechBoundTemplates: 0,
})
require.False(t, p.UseTechFilter)
require.Contains(t, p.Reason, "tech-filter=skipped-no-bound-templates")
}
func TestDecideTechFilterOn(t *testing.T) {
p := Decide(Input{
Hosts: 5,
Templates: 100,
Requests: 500,
TechFilter: true,
TechBoundTemplates: 40,
})
require.True(t, p.UseTechFilter)
require.Contains(t, p.Reason, "tech-filter=on")
}
-343
View File
@@ -1,343 +0,0 @@
// Package techfilter maps Wappalyzer products/categories to template tags and
// decides whether a template can produce a finding on a fingerprinted host.
//
// Fail-open rules (coverage over aggressiveness):
// - templates with no product/macro tags always run
// - hosts with no fingerprints always allow every template
// - CDN/WAF-only fingerprints do not suppress product templates
// - non-HTTP templates are not filtered here (reachability owns that)
package techfilter
import (
"encoding/json"
"strings"
"sync"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
wappalyzer "github.com/projectdiscovery/wappalyzergo"
)
// genericTags are common nuclei tags that do not bind a template to a product.
var genericTags = map[string]struct{}{
"cve": {}, "cnvd": {}, "edb": {}, "kev": {}, "vkev": {},
"misconfig": {}, "exposure": {}, "config": {}, "disclosure": {},
"tech": {}, "detect": {}, "fingerprint": {}, "favicon": {}, "waf": {},
"http": {}, "network": {}, "dns": {}, "ssl": {}, "tls": {},
"websocket": {}, "headless": {}, "file": {}, "code": {}, "javascript": {},
"workflow": {}, "intrusive": {}, "dos": {}, "fuzz": {}, "token-spray": {},
"osint": {}, "panel": {}, "login": {}, "auth": {}, "unauth": {},
"default-login": {}, "vuln": {}, "rce": {}, "lfi": {}, "sqli": {},
"xss": {}, "ssrf": {}, "xxe": {}, "ssti": {}, "idor": {},
"critical": {}, "high": {}, "medium": {}, "low": {}, "info": {},
"unknown": {}, "generic": {}, "misc": {}, "bench": {},
// Meta / source / taxonomy tags that are not products.
"oast": {}, "takeover": {}, "deserialization": {}, "packetstorm": {},
"seclists": {}, "hackerone": {}, "huntr": {}, "wp-plugin": {}, "wp-theme": {},
"wordpress-plugin": {}, "wordpress-theme": {}, "authenticated": {},
"unauthenticated": {}, "bypass": {}, "injection": {}, "traversal": {},
"redirect": {}, "crlf": {}, "csrf": {}, "cors": {},
}
// weakFingerprintTags alone must not suppress product-bound templates
// (e.g. cloudflare/cdn-only fingerprints).
var weakFingerprintTags = map[string]struct{}{
"cdn": {}, "waf": {}, "misc": {}, "security": {}, "caching": {},
}
// productAliases normalize common nuclei / wappalyzer spellings to one token.
var productAliases = map[string]string{
"wp": "wordpress",
"httpd": "apache",
"apache-http-server": "apache",
}
// categoryMacros maps Wappalyzer category names to short macro tags.
var categoryMacros = map[string]string{
"cms": "cms",
"ecommerce": "ecommerce",
"web servers": "webserver",
"web-servers": "webserver",
"web frameworks": "web-framework",
"web-frameworks": "web-framework",
"javascript frameworks": "js-framework",
"javascript-frameworks": "js-framework",
"javascript libraries": "js-library",
"javascript-libraries": "js-library",
"cdn": "cdn",
"databases": "database",
"programming languages": "language",
"programming-languages": "language",
"paas": "paas",
"saas": "saas",
"security": "security",
"caching": "caching",
"miscellaneous": "misc",
}
// HostProfile is the set of product + macro tags inferred for one target.
type HostProfile struct {
Tags map[string]struct{} // empty / nil => unknown => fail-open
// Cats are the Wappalyzer category ids of every detected product. They
// record what the fingerprint actually has evidence about, which is what
// makes it safe to rule a template out. See AllowTags.
Cats map[int]struct{}
}
// HasTags reports whether fingerprinting produced at least one tag.
func (h HostProfile) HasTags() bool {
return len(h.Tags) > 0
}
// HasSubstantiveTags reports whether the profile has a strong category macro
// (cms, webserver, …). CDN/WAF-only fingerprints are not substantive.
func (h HostProfile) HasSubstantiveTags() bool {
for tag := range h.Tags {
if isStrongMacro(tag) {
return true
}
}
return false
}
func isStrongMacro(tag string) bool {
if _, weak := weakFingerprintTags[tag]; weak {
return false
}
for _, m := range categoryMacros {
if tag == m {
return true
}
}
return false
}
// NormalizeProduct turns a Wappalyzer / nuclei product name into a tag token.
func NormalizeProduct(name string) string {
name = strings.TrimSpace(strings.ToLower(name))
if name == "" {
return ""
}
if i := strings.IndexByte(name, ':'); i > 0 {
name = name[:i]
}
name = strings.ReplaceAll(name, "_", "-")
name = strings.Join(strings.Fields(name), "-")
if alias, ok := productAliases[name]; ok {
return alias
}
return name
}
// NormalizeCategory turns a Wappalyzer category into a macro tag.
func NormalizeCategory(cat string) string {
key := strings.ToLower(strings.TrimSpace(cat))
if key == "" {
return ""
}
if m, ok := categoryMacros[key]; ok {
return m
}
return strings.ReplaceAll(key, " ", "-")
}
// IsGenericTag reports whether tag is not product/macro binding.
func IsGenericTag(tag string) bool {
tag = strings.ToLower(strings.TrimSpace(tag))
if tag == "" {
return true
}
if _, ok := genericTags[tag]; ok {
return true
}
// cve2021 / cve-2021 style year tags bind no product.
if strings.HasPrefix(tag, "cve") {
rest := strings.TrimPrefix(tag, "cve")
rest = strings.TrimPrefix(rest, "-")
if rest != "" && isAllDigits(rest) {
return true
}
}
return false
}
func isAllDigits(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
// ProfileFromFingerprint builds a host profile from products and their categories.
// products maps product name -> category names from Wappalyzer.
func ProfileFromFingerprint(products map[string][]string) HostProfile {
tags := make(map[string]struct{})
observed := make(map[int]struct{})
byProduct := productCategories()
for product, cats := range products {
if p := NormalizeProduct(product); p != "" {
tags[p] = struct{}{}
for _, id := range byProduct[p] {
observed[id] = struct{}{}
}
}
for _, c := range cats {
if m := NormalizeCategory(c); m != "" {
tags[m] = struct{}{}
}
}
}
return HostProfile{Tags: tags, Cats: observed}
}
// TemplateProductTags returns product/macro tags that bind a template to a stack.
// Empty means the template is generic and must always be allowed.
func TemplateProductTags(t *templates.Template) []string {
if t == nil {
return nil
}
raw := t.Info.Tags.ToSlice()
out := make([]string, 0, len(raw))
seen := make(map[string]struct{}, len(raw))
known := knownFingerprintTokens()
for _, tag := range raw {
tag = NormalizeProduct(tag)
if tag == "" || IsGenericTag(tag) {
continue
}
// A tag binds only when fingerprinting can actually emit it. Anything
// else would never match a profile and would prune the template on
// every host, so it must leave the template unbound.
if _, ok := known[tag]; !ok {
continue
}
if _, ok := seen[tag]; ok {
continue
}
seen[tag] = struct{}{}
out = append(out, tag)
}
return out
}
// knownFingerprintTokens is every token a host profile can contain: the
// normalized name of every Wappalyzer app plus every category macro.
//
// This is an allowlist on purpose. nuclei-templates carries thousands of tags
// that name no product at all (discovery, wpscan, top-200, auth-bypass, ...).
// Treating an unrecognized tag as a product binds the template to something no
// fingerprint can ever report, which silently drops it on every host. Unknown
// tags must therefore bind nothing.
var knownFingerprintTokens = sync.OnceValue(func() map[string]struct{} {
var data struct {
Apps map[string]json.RawMessage `json:"apps"`
}
tokens := make(map[string]struct{}, 8192)
if err := json.Unmarshal([]byte(wappalyzer.GetFingerprints()), &data); err == nil {
for name := range data.Apps {
if p := NormalizeProduct(name); p != "" {
tokens[p] = struct{}{}
}
}
}
for _, macro := range categoryMacros {
tokens[macro] = struct{}{}
}
for tag := range weakFingerprintTags {
tokens[tag] = struct{}{}
}
return tokens
})
// IsTechBound reports whether the template is constrained to a product/macro.
func IsTechBound(t *templates.Template) bool {
return len(TemplateProductTags(t)) > 0
}
// Allow reports whether template may run on a host with the given profile.
// Fail-open when the host was not fingerprinted or the template is unbound.
func Allow(profile HostProfile, t *templates.Template) bool {
return AllowTags(profile, TemplateProductTags(t))
}
// AllowTags is Allow with precomputed product tags (avoids per-pair allocation).
func AllowTags(profile HostProfile, bound []string) bool {
if len(bound) == 0 {
return true
}
if !profile.HasTags() || !profile.HasSubstantiveTags() {
return true
}
for _, tag := range bound {
if _, ok := profile.Tags[tag]; ok {
return true
}
}
// No positive match. Ruling the template out now would assume the
// fingerprint is complete, and it never is: it is one page of one host, and
// a stack is routinely invisible from it (WordPress behind nginx is the
// ordinary case, not the exception). Absence of evidence is not evidence of
// absence, so a template is dropped only when the fingerprint positively
// contradicts it: something else was identified in the same category, and
// the template's own product was not. If any bound product sits in a
// category the fingerprint says nothing about, the template runs.
byProduct := productCategories()
for _, tag := range bound {
cats, ok := byProduct[tag]
if !ok || len(cats) == 0 {
return true // category macro or unknown product: no contradiction possible
}
contradicted := false
for _, id := range cats {
if _, seen := profile.Cats[id]; seen {
contradicted = true
break
}
}
if !contradicted {
return true
}
}
return false
}
// productCategories maps a normalized product token to its Wappalyzer category
// ids. It is the evidence index: a category id present in a host profile means
// fingerprinting actually identified something of that kind on that host.
var productCategories = sync.OnceValue(func() map[string][]int {
var data struct {
Apps map[string]struct {
Cats []int `json:"cats"`
} `json:"apps"`
}
out := make(map[string][]int, 8192)
if err := json.Unmarshal([]byte(wappalyzer.GetFingerprints()), &data); err != nil {
return out
}
for name, app := range data.Apps {
p := NormalizeProduct(name)
if p == "" || len(app.Cats) == 0 {
continue
}
// Aliases collapse several apps onto one token; union their categories.
out[p] = append(out[p], app.Cats...)
}
return out
})
// CountTechBound returns how many templates carry product/macro tags.
func CountTechBound(tpls []*templates.Template) int {
n := 0
for _, t := range tpls {
if IsTechBound(t) {
n++
}
}
return n
}
-208
View File
@@ -1,208 +0,0 @@
package techfilter
import (
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/model"
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/stringslice"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
)
func TestNormalizeProductAndCategory(t *testing.T) {
if got := NormalizeProduct("Nginx:1.25"); got != "nginx" {
t.Fatalf("NormalizeProduct = %q", got)
}
if got := NormalizeProduct("wp"); got != "wordpress" {
t.Fatalf("alias = %q", got)
}
if got := NormalizeCategory("Web servers"); got != "webserver" {
t.Fatalf("category = %q", got)
}
}
func TestProfileFromFingerprint(t *testing.T) {
p := ProfileFromFingerprint(map[string][]string{
"Nginx": {"Web servers"},
"WordPress": {"CMS"},
})
for _, want := range []string{"nginx", "webserver", "wordpress", "cms"} {
if _, ok := p.Tags[want]; !ok {
t.Fatalf("missing tag %q in %#v", want, p.Tags)
}
}
}
func tagged(tags ...string) *templates.Template {
return &templates.Template{
Info: model.Info{Tags: stringslice.StringSlice{Value: tags}},
}
}
func TestAllowFailOpenAndMatch(t *testing.T) {
nginx := ProfileFromFingerprint(map[string][]string{"Nginx": {"Web servers"}})
empty := HostProfile{}
generic := tagged("misconfig", "http")
wp := tagged("cve", "wordpress")
ngx := tagged("nginx", "misconfig")
cms := tagged("cms")
if !Allow(nginx, generic) {
t.Fatal("generic must always run")
}
if !Allow(empty, wp) {
t.Fatal("unknown host must fail-open for bound templates")
}
// An nginx fingerprint says nothing about the CMS behind it, and WordPress
// behind nginx is an ordinary stack. Absence of a CMS in the profile is not
// evidence that there is none.
if !Allow(nginx, wp) {
t.Fatal("nginx says nothing about a CMS, so a wordpress template must run")
}
if !Allow(nginx, ngx) {
t.Fatal("nginx template must run on nginx host")
}
if !Allow(nginx, cms) {
t.Fatal("nginx says nothing about a CMS, so a cms-bound template must run")
}
wpHost := ProfileFromFingerprint(map[string][]string{"WordPress": {"CMS"}})
if !Allow(wpHost, cms) {
t.Fatal("cms macro must match wordpress host")
}
if !Allow(wpHost, wp) {
t.Fatal("wordpress product must match")
}
}
func TestMetaTagsAreNotTechBound(t *testing.T) {
cases := [][]string{
{"cve", "cve2021", "oast", "rce"},
{"takeover", "dns"},
{"hackerone", "packetstorm", "seclists"},
{"wp-plugin", "wordpress-plugin"},
}
for _, tags := range cases {
tpl := tagged(tags...)
if IsTechBound(tpl) {
t.Fatalf("tags %v must not be tech-bound, got %v", tags, TemplateProductTags(tpl))
}
cdnOnly := ProfileFromFingerprint(map[string][]string{"Cloudflare": {"CDN"}})
if !Allow(cdnOnly, tpl) {
t.Fatalf("unbound meta tags %v must run on any host", tags)
}
}
}
func TestCDNOnlyFingerprintFailOpen(t *testing.T) {
cdnOnly := ProfileFromFingerprint(map[string][]string{"Cloudflare": {"CDN"}})
if cdnOnly.HasSubstantiveTags() {
t.Fatalf("cdn-only profile should not be substantive: %#v", cdnOnly.Tags)
}
wp := tagged("wordpress")
if !Allow(cdnOnly, wp) {
t.Fatal("CDN-only fingerprint must fail-open for product templates")
}
}
func TestCountTechBound(t *testing.T) {
n := CountTechBound([]*templates.Template{
tagged("misconfig"),
tagged("wordpress"),
tagged("nginx", "http"),
tagged("cve2023", "oast"),
})
if n != 2 {
t.Fatalf("CountTechBound = %d", n)
}
}
// A tag only binds when fingerprinting can emit it. nuclei-templates carries
// thousands of tags that name no product; binding to one of those prunes the
// template on every host, because no profile can ever contain it.
func TestOnlyKnownFingerprintTokensBind(t *testing.T) {
nonProducts := [][]string{
{"misconfig", "headers", "generic", "vuln"}, // http-missing-security-headers
{"tech", "discovery"}, // tech-detect
{"waf", "tech", "misc", "discovery"}, // waf-detect
{"wpscan"},
{"top-100"}, {"top-200"},
{"auth-bypass"}, {"time-based-sqli"},
{"install"}, {"files"}, {"file-upload"},
{"phishing"}, {"osint-social"},
{"debug"}, {"devops"}, {"token"}, {"tokens"}, {"fpd"}, {"oss"},
}
nginx := ProfileFromFingerprint(map[string][]string{"Nginx": {"Web servers"}})
for _, tags := range nonProducts {
tpl := tagged(tags...)
if got := TemplateProductTags(tpl); len(got) != 0 {
t.Fatalf("tags %v must not bind, got %v", tags, got)
}
if !Allow(nginx, tpl) {
t.Fatalf("unbound template %v must run on every host", tags)
}
}
}
// The inversion must not disable the filter: real products still bind, and a
// template for another stack is still pruned.
func TestKnownProductsStillBind(t *testing.T) {
for _, tags := range [][]string{{"wordpress"}, {"apache"}, {"joomla"}, {"cms"}} {
if got := TemplateProductTags(tagged(tags...)); len(got) == 0 {
t.Fatalf("tags %v name a product and must bind", tags)
}
}
wp := ProfileFromFingerprint(map[string][]string{"WordPress": {"CMS"}})
if !Allow(wp, tagged("wordpress", "rce")) {
t.Fatal("a wordpress template must run on a wordpress profile")
}
}
// A template is dropped only when the fingerprint contradicts it: it identified
// something else of the same kind and not the template's own product.
func TestPruneOnlyOnPositiveContradiction(t *testing.T) {
nginx := ProfileFromFingerprint(map[string][]string{"Nginx": {"Web servers"}})
wpHost := ProfileFromFingerprint(map[string][]string{
"WordPress": {"CMS"}, "Nginx": {"Web servers"},
})
// Contradiction: a web server was identified and it is not apache.
if Allow(nginx, tagged("apache")) {
t.Fatal("nginx was identified, so an apache template is contradicted")
}
// Contradiction: a CMS was identified and it is not joomla.
if Allow(wpHost, tagged("joomla")) {
t.Fatal("wordpress was identified, so a joomla template is contradicted")
}
// No evidence: nothing of that kind was identified at all.
for _, tags := range [][]string{{"wordpress"}, {"joomla"}, {"cms"}, {"mysql"}} {
if !Allow(nginx, tagged(tags...)) {
t.Fatalf("nginx gives no evidence about %v, so it must run", tags)
}
}
// One uncontradicted product is enough to keep a multi-product template.
if !Allow(nginx, tagged("apache", "wordpress")) {
t.Fatal("wordpress is uncontradicted, so the template must run")
}
// A positive match always wins.
if !Allow(wpHost, tagged("wordpress")) {
t.Fatal("a positive match must run")
}
}
func TestKnownFingerprintTokensVocabulary(t *testing.T) {
tokens := knownFingerprintTokens()
if len(tokens) < 5000 {
t.Fatalf("expected the wappalyzer vocabulary, got %d tokens", len(tokens))
}
for _, want := range []string{"wordpress", "nginx", "apache", "cms", "webserver"} {
if _, ok := tokens[want]; !ok {
t.Fatalf("token %q missing from the vocabulary", want)
}
}
for _, bad := range []string{"discovery", "headers", "wpscan", "top-200"} {
if _, ok := tokens[bad]; ok {
t.Fatalf("token %q is not a product and must not be in the vocabulary", bad)
}
}
}
-14
View File
@@ -16,14 +16,6 @@ import (
// Helper is a structure for helping with input transformation
type Helper struct {
InputsHTTP *hybrid.HybridMap
// InputsHTTPProbed is true when InputsHTTP was filled by httpx probing.
// False means probing was skipped (e.g. MultiFormat) and map absence must
// not be treated as "not HTTP".
InputsHTTPProbed bool
// StrictProbe, when set, makes web-template input resolution skip inputs that
// were probed and found NOT to be HTTP services (instead of falling back to
// a raw URL). Lossless: a non-HTTP port has no HTTP application to find.
StrictProbe bool
}
// NewHelper returns a new input helper instance
@@ -148,12 +140,6 @@ func (h *Helper) convertInputToType(input string, inputType inputType, defaultPo
if probed, ok := h.InputsHTTP.Get(input); ok {
return string(probed)
}
// Strict reachability: this input was probed and is not an HTTP
// service, so skip it for web templates rather than falling back to
// a raw URL. Lossless — a non-HTTP port has no HTTP app to match.
if h.StrictProbe {
return ""
}
}
// try to parse it as absolute url and return
if absUrl, err := urlutil.ParseAbsoluteURL(input, false); err == nil {
-29
View File
@@ -91,32 +91,3 @@ func TestConvertInputToType(t *testing.T) {
require.Equal(t, test.result, result, "could not get correct result %+v", test)
}
}
func TestStrictProbeSkipsUnconfirmedHTTPInput(t *testing.T) {
hm, err := hybrid.New(hybrid.DefaultDiskOptions)
require.NoError(t, err)
t.Cleanup(func() { _ = hm.Close() })
_ = hm.Set("ok.example", []byte("https://ok.example"))
helper := &Helper{InputsHTTP: hm, StrictProbe: true}
// confirmed host -> resolved URL
require.Equal(t, "https://ok.example", helper.convertInputToType("ok.example", typeURL, ""))
// probed but non-HTTP hosts -> skipped (no raw fallback)
require.Equal(t, "", helper.convertInputToType("127.0.0.1:3306", typeURL, ""))
require.Equal(t, "", helper.convertInputToType("zombie.example:6379", typeURL, ""))
// already-URL inputs are not gated by probe results
require.Equal(t, "https://direct.example", helper.convertInputToType("https://direct.example", typeURL, ""))
// non-HTTP protocols still transform normally
require.Equal(t, "127.0.0.1:3306", helper.convertInputToType("127.0.0.1:3306", typeHostWithOptionalPort, ""))
}
func TestStrictProbeDisabledKeepsRawFallback(t *testing.T) {
hm, err := hybrid.New(hybrid.DefaultDiskOptions)
require.NoError(t, err)
t.Cleanup(func() { _ = hm.Close() })
helper := &Helper{InputsHTTP: hm, StrictProbe: false}
require.Equal(t, "127.0.0.1:3306", helper.convertInputToType("127.0.0.1:3306", typeURL, ""))
}
+30 -5
View File
@@ -46,13 +46,27 @@ func TestNewCompilerConsoleDebug(t *testing.T) {
}
}
func outsideModulePath(t *testing.T, name string) string {
t.Helper()
// os.UserHomeDir (not os.Getenv("HOME")) so this resolves to an absolute,
// writable location outside the templates allowlist on every OS. HOME is
// unset on GitHub Actions windows-latest, which would otherwise collapse to
// a relative path resolved under the templates dir and void the denial test.
home, err := os.UserHomeDir()
require.NoError(t, err)
outsideDir := filepath.Join(home, ".nuclei-test-outside-"+t.Name())
require.NoError(t, os.MkdirAll(outsideDir, 0o700))
t.Cleanup(func() { _ = os.RemoveAll(outsideDir) })
return writeModuleFile(t, outsideDir, name, `module.exports = { value: "outside-secret" };`)
}
func TestRequireLocalFileAccessDenied(t *testing.T) {
modulePath := writeModuleFile(t, t.TempDir(), "outside.js", `module.exports = { value: "outside-secret" };`)
modulePath := outsideModulePath(t, "outside.js")
script := fmt.Sprintf(`var helper = require(%q); ExportAs("value", helper.value); true;`, modulePath)
result, err := executeScript(t, t.Name(), false, script)
require.Error(t, err)
require.Contains(t, err.Error(), "-lfa is not enabled")
require.Contains(t, err.Error(), "outside")
require.Equal(t, err.Error(), result["error"])
}
@@ -74,7 +88,13 @@ func TestRequireTemplateModuleAllowedWithoutLFA(t *testing.T) {
}
func TestRequireLocalFileAccessAllowed(t *testing.T) {
modulePath := writeModuleFile(t, t.TempDir(), "outside.js", `module.exports = { value: "outside-ok" };`)
// -lfa grants the working directory; the system temp dir is never granted.
cwd, err := os.Getwd()
require.NoError(t, err)
moduleDir := filepath.Join(cwd, ".nuclei-module-lfa-test-"+t.Name())
require.NoError(t, os.MkdirAll(moduleDir, 0o700))
t.Cleanup(func() { _ = os.RemoveAll(moduleDir) })
modulePath := writeModuleFile(t, moduleDir, "outside.js", `module.exports = { value: "outside-ok" };`)
script := fmt.Sprintf(`var helper = require(%q); ExportAs("value", helper.value); true;`, modulePath)
result, err := executeScript(t, t.Name(), true, script)
@@ -83,7 +103,12 @@ func TestRequireLocalFileAccessAllowed(t *testing.T) {
}
func TestRequireDoesNotReusePrivilegedModuleCacheAcrossExecutions(t *testing.T) {
modulePath := writeModuleFile(t, t.TempDir(), "outside.js", `module.exports = { value: "outside-ok" };`)
cwd, err := os.Getwd()
require.NoError(t, err)
moduleDir := filepath.Join(cwd, ".nuclei-module-cache-test-"+t.Name())
require.NoError(t, os.MkdirAll(moduleDir, 0o700))
t.Cleanup(func() { _ = os.RemoveAll(moduleDir) })
modulePath := writeModuleFile(t, moduleDir, "outside.js", `module.exports = { value: "outside-ok" };`)
program, err := goja.Compile("", fmt.Sprintf(`require(%q).value`, modulePath), false)
require.NoError(t, err)
@@ -103,7 +128,7 @@ func TestRequireDoesNotReusePrivilegedModuleCacheAcrossExecutions(t *testing.T)
ExecutionId: denyExecutionID,
}, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "-lfa is not enabled")
require.Contains(t, err.Error(), "outside")
}
func TestExecuteWithRuntimeCleansUpAfterCallbackPanic(t *testing.T) {
+1 -1
View File
@@ -211,7 +211,7 @@ func (c *Client) RpcDump(ctx context.Context) ([]Endpoint, error) {
if dialer == nil {
return nil, fmt.Errorf("dialers not initialized for execution %s", c.nj.ExecutionId())
}
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(c.Host, strconv.Itoa(135)))
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, c.nj.ExecutionId(), "tcp", net.JoinHostPort(c.Host, strconv.Itoa(135)))
if err != nil {
return nil, fmt.Errorf("dial epmapper: %w", err)
}
+10 -7
View File
@@ -2,9 +2,11 @@ package fs
import (
"context"
"fmt"
"os"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
)
// ListDir lists itemType values within a directory
@@ -29,7 +31,10 @@ import (
// const items = fs.ListDir('/tmp');
// ```
func ListDir(ctx context.Context, path string, itemType string) ([]string, error) {
executionId := ctx.Value("executionId").(string)
executionId := protocolstate.ExecutionIDFromContext(ctx)
if executionId == "" {
return nil, fmt.Errorf("fs: missing executionId")
}
finalPath, err := protocolstate.NormalizePathWithExecutionId(executionId, path)
if err != nil {
return nil, err
@@ -60,13 +65,11 @@ func ListDir(ctx context.Context, path string, itemType string) ([]string, error
// const content = fs.ReadFile('helpers/usernames.txt');
// ```
func ReadFile(ctx context.Context, path string) ([]byte, error) {
executionId := ctx.Value("executionId").(string)
finalPath, err := protocolstate.NormalizePathWithExecutionId(executionId, path)
if err != nil {
return nil, err
executionId := protocolstate.ExecutionIDFromContext(ctx)
if executionId == "" {
return nil, fmt.Errorf("fs: missing executionId")
}
bin, err := os.ReadFile(finalPath)
return bin, err
return protocolstate.ReadFileAllowed(&types.Options{ExecutionId: executionId}, path)
}
// ReadFileAsString reads file contents within permitted paths
+72
View File
@@ -0,0 +1,72 @@
package fs
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/gologger/levels"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/stretchr/testify/require"
)
func TestReadFileAllowsTemplatesPath(t *testing.T) {
gologger.DefaultLogger.SetMaxLevel(levels.LevelSilent)
templatesDir := t.TempDir()
secret := []byte("fs-module-secret")
path := filepath.Join(templatesDir, "allowed.txt")
require.NoError(t, os.WriteFile(path, secret, 0o600))
old := config.DefaultConfig.TemplatesDirectory
config.DefaultConfig.SetTemplatesDir(templatesDir)
t.Cleanup(func() { config.DefaultConfig.SetTemplatesDir(old) })
executionID := t.Name()
protocolstate.SetLfaAllowed(&types.Options{ExecutionId: executionID, AllowLocalFileAccess: false})
ctx := context.WithValue(context.Background(), "executionId", executionID) //nolint:staticcheck
got, err := ReadFile(ctx, path)
require.NoError(t, err)
require.Equal(t, secret, got)
}
func TestReadFileRejectsPathOutsideAllowlist(t *testing.T) {
gologger.DefaultLogger.SetMaxLevel(levels.LevelSilent)
templatesDir := t.TempDir()
old := config.DefaultConfig.TemplatesDirectory
config.DefaultConfig.SetTemplatesDir(templatesDir)
t.Cleanup(func() { config.DefaultConfig.SetTemplatesDir(old) })
home, err := os.UserHomeDir()
require.NoError(t, err)
outside := filepath.Join(home, ".nuclei-fs-module-"+t.Name(), "secret.txt")
require.NoError(t, os.MkdirAll(filepath.Dir(outside), 0o700))
require.NoError(t, os.WriteFile(outside, []byte("x"), 0o600))
t.Cleanup(func() { _ = os.RemoveAll(filepath.Dir(outside)) })
executionID := t.Name()
protocolstate.SetLfaAllowed(&types.Options{ExecutionId: executionID, AllowLocalFileAccess: false})
ctx := context.WithValue(context.Background(), "executionId", executionID) //nolint:staticcheck
_, err = ReadFile(ctx, outside)
require.Error(t, err)
}
func TestListDirRejectsOutsideAllowlist(t *testing.T) {
gologger.DefaultLogger.SetMaxLevel(levels.LevelSilent)
templatesDir := t.TempDir()
old := config.DefaultConfig.TemplatesDirectory
config.DefaultConfig.SetTemplatesDir(templatesDir)
t.Cleanup(func() { config.DefaultConfig.SetTemplatesDir(old) })
executionID := t.Name()
protocolstate.SetLfaAllowed(&types.Options{ExecutionId: executionID, AllowLocalFileAccess: false})
ctx := context.WithValue(context.Background(), "executionId", executionID) //nolint:staticcheck
_, err := ListDir(ctx, "/etc", "file")
require.Error(t, err)
}
+3 -6
View File
@@ -41,7 +41,8 @@ func NewExecDialer(execID string) *gptr.Dialer {
}
}
// DialWithExec performs the fastdialer dial after enforcing host policy.
// DialWithExec performs the fastdialer dial after enforcing host policy via the
// centralized dial broker (fail-closed when dialers are missing).
func DialWithExec(ctx context.Context, execID, network, address string) (net.Conn, error) {
host, _, err := net.SplitHostPort(address)
if err != nil {
@@ -50,11 +51,7 @@ func DialWithExec(ctx context.Context, execID, network, address string) (net.Con
if !protocolstate.IsHostAllowed(execID, host) {
return nil, protocolstate.ErrHostDenied.Msgf(host)
}
dialer := protocolstate.GetDialersWithId(execID)
if dialer == nil || dialer.Fastdialer == nil {
return nil, fmt.Errorf("goimpacket: no fastdialer registered for executionId %q", execID)
}
return dialer.Fastdialer.Dial(ctx, network, address)
return protocolstate.DialAllowedWithExecutionID(ctx, execID, network, address)
}
// ExecutionIDFromCtx pulls the executionId set by nuclei on its goja runtime
+17 -3
View File
@@ -193,11 +193,25 @@ func TestProtosetOutsideTemplatesDirDenied(t *testing.T) {
setTemplateDir(t)
executionID := initExec(t, &types.Options{AllowLocalFileAccess: false})
// write the protoset outside the templates directory
outside := filepath.Join(t.TempDir(), "health.protoset")
// t.TempDir() lives under os.TempDir(), which is always in the filesystem
// allowlist. Create a unique directory under $HOME and skip if HOME itself
// is inside that allowlist.
home, err := os.UserHomeDir()
require.NoError(t, err)
absHome, err := filepath.Abs(home)
require.NoError(t, err)
absTemp, err := filepath.Abs(os.TempDir())
require.NoError(t, err)
if strings.HasPrefix(absHome+string(os.PathSeparator), absTemp+string(os.PathSeparator)) || absHome == absTemp {
t.Skip("$HOME is under the temp root, so it is already allowlisted")
}
outsideDir, err := os.MkdirTemp(home, "nuclei-grpc-outside-*")
require.NoError(t, err)
t.Cleanup(func() { _ = os.RemoveAll(outsideDir) })
outside := filepath.Join(outsideDir, "health.protoset")
require.NoError(t, os.WriteFile(outside, healthProtoset(t), 0o600))
_, _, err := descriptorSource(context.Background(), executionID, nil, outside)
_, _, err = descriptorSource(context.Background(), executionID, nil, outside)
require.Error(t, err)
require.Contains(t, err.Error(), "protoset path denied")
}
+11 -14
View File
@@ -4,10 +4,10 @@ import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"os"
"strings"
"github.com/fullstorydev/grpcurl"
@@ -20,6 +20,7 @@ import (
"google.golang.org/protobuf/types/descriptorpb"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
)
// connConfig holds the low level knobs used to build a gRPC client connection.
@@ -34,7 +35,7 @@ type connConfig struct {
// dialTarget builds a *grpc.ClientConn whose every connection is routed through
// nuclei's network policy. The host is validated up front and the actual dial
// is delegated to the execution's fastdialer via a custom context dialer, so
// is delegated to the execution's fastdialer via DialAllowedWithExecutionID, so
// IP/host denylists and RestrictLocalNetworkAccess are always enforced. The
// passthrough scheme guarantees the target is handed verbatim to our dialer
// (instead of gRPC's built in DNS resolver), keeping resolution and policy
@@ -53,13 +54,9 @@ func dialTarget(ctx context.Context, executionID, target string, cfg connConfig)
if !protocolstate.IsHostAllowed(executionID, host) {
return nil, protocolstate.ErrHostDenied.Msgf(host)
}
dialers := protocolstate.GetDialersWithId(executionID)
if dialers == nil || dialers.Fastdialer == nil {
return nil, fmt.Errorf("grpc: dialers not initialized for executionId %q", executionID)
}
contextDialer := func(dialCtx context.Context, addr string) (net.Conn, error) {
return dialers.Fastdialer.Dial(dialCtx, "tcp", addr)
return protocolstate.DialAllowedWithExecutionID(dialCtx, executionID, "tcp", addr)
}
var creds credentials.TransportCredentials
@@ -95,14 +92,14 @@ func dialTarget(ctx context.Context, executionID, target string, cfg connConfig)
func descriptorSource(ctx context.Context, executionID string, cc *grpc.ClientConn, protosetFile string) (grpcurl.DescriptorSource, func(), error) {
noop := func() {}
if strings.TrimSpace(protosetFile) != "" {
// resolve through the local-file-access allowlist: unless -lfa is set,
// only files inside the nuclei-templates directory are permitted.
normalized, err := protocolstate.NormalizePathWithExecutionId(executionID, protosetFile)
if err != nil {
return nil, noop, fmt.Errorf("protoset path denied: %w", err)
}
data, err := os.ReadFile(normalized)
// resolve + read through the filesystem allowlist: unless -lfa /
// --allowed-paths expand it, only templates/config/temp roots are
// permitted.
data, err := protocolstate.ReadFileAllowed(&types.Options{ExecutionId: executionID}, protosetFile)
if err != nil {
if errors.Is(err, protocolstate.ErrPathDenied) {
return nil, noop, fmt.Errorf("protoset path denied: %w", err)
}
return nil, noop, fmt.Errorf("failed to read protoset file: %w", err)
}
fds := &descriptorpb.FileDescriptorSet{}
@@ -0,0 +1,40 @@
package jslibs
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestJSLibsDoNotDialFastdialerDirectly(t *testing.T) {
root := ".."
var offenders []string
err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
if path != root && d.Name() == "guard" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
content := string(data)
if strings.Contains(content, "Fastdialer.Dial") || strings.Contains(content, "Fastdialer.DialTLS") {
offenders = append(offenders, path)
}
return nil
})
requireNoError(t, err)
if len(offenders) > 0 {
t.Fatalf("js libs must dial via protocolstate.DialAllowed, offenders: %v", offenders)
}
}
+76
View File
@@ -0,0 +1,76 @@
package jslibs
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
)
// forbiddenSelectors is keyed by the package *identifier* used at the call
// site, not the import path. `import "os/exec"` binds the identifier `exec`, so
// a direct `exec.Command(...)` yields the selector key "exec.Command" — the
// import path form "os/exec.Command" would never match.
var forbiddenSelectors = map[string]struct{}{
"net.Dial": {},
"net.Dialer": {},
"exec.Command": {},
"os.Open": {},
"os.ReadFile": {},
"os.WriteFile": {},
}
func TestJSLibsDoNotUseForbiddenSyscalls(t *testing.T) {
root := ".."
err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
if path != root && d.Name() == "guard" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return err
}
// Inspect every selector expression (pkg.Name), not just call
// expressions: this catches type references such as net.Dialer and
// composite literals like net.Dialer{}, not only direct calls.
ast.Inspect(file, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if !ok {
return true
}
pkg, ok := sel.X.(*ast.Ident)
if !ok {
return true
}
key := pkg.Name + "." + sel.Sel.Name
if _, blocked := forbiddenSelectors[key]; blocked {
t.Fatalf("%s uses forbidden selector %s", path, key)
}
return true
})
return nil
})
requireNoError(t, err)
}
func requireNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
+6 -7
View File
@@ -269,11 +269,6 @@ func (c *Client) do(ctx context.Context, method, rawURL, body string) (*Response
return nil, protocolstate.ErrHostDenied.Msgf(host)
}
dialers := protocolstate.GetDialersWithId(executionID)
if dialers == nil {
return nil, fmt.Errorf("dialers not initialized for %s", executionID)
}
tlsConfig := &tls.Config{
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS10,
@@ -290,10 +285,14 @@ func (c *Client) do(ctx context.Context, method, rawURL, body string) (*Response
transport := &http.Transport{
Proxy: proxy,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialers.Fastdialer.Dial(ctx, network, addr)
return protocolstate.DialAllowedWithExecutionID(ctx, executionID, network, addr)
},
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialers.Fastdialer.DialTLSWithConfig(ctx, network, addr, tlsConfig)
cfg := tlsConfig.Clone()
if h, _, err := net.SplitHostPort(addr); err == nil && h != "" && net.ParseIP(h) == nil {
cfg.ServerName = h
}
return protocolstate.DialTLSAllowedWithExecutionID(ctx, executionID, network, addr, cfg)
},
TLSClientConfig: tlsConfig,
ForceAttemptHTTP2: true,
+2 -2
View File
@@ -81,7 +81,7 @@ func sendToKDCTcp(kclient *Client, msg string) ([]byte, error) {
// use that ip address instead of realm/domain for resolving
host = kclient.config.ip
}
tcpConn, err := dialers.Fastdialer.Dial(dialCtx, "tcp", net.JoinHostPort(host, port))
tcpConn, err := protocolstate.DialAllowedWithExecutionID(dialCtx, executionId, "tcp", net.JoinHostPort(host, port))
if err != nil {
errs = append(errs, fmt.Sprintf("error establishing connection to %s: %v", kdcs[i], err))
continue
@@ -122,7 +122,7 @@ func sendToKDCUdp(kclient *Client, msg string) ([]byte, error) {
// use that ip address instead of realm/domain for resolving
host = kclient.config.ip
}
udpConn, err := dialers.Fastdialer.Dial(dialCtx, "udp", net.JoinHostPort(host, port))
udpConn, err := protocolstate.DialAllowedWithExecutionID(dialCtx, executionId, "udp", net.JoinHostPort(host, port))
if err != nil {
errs = append(errs, fmt.Sprintf("error establishing connection to %s: %v", kdcs[i], err))
continue
+28 -3
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/projectdiscovery/goja"
@@ -19,7 +20,10 @@ func TestCreateTicketDefaultOutput(t *testing.T) {
t.Run(fmt.Sprintf("golden=%v/%s", golden, location), func(t *testing.T) {
templatesDir := setTemplateDir(t)
cwd := templatesDir
if location != "sandbox" {
switch location {
case "outside denied":
cwd = outsideAllowedDir(t)
case "outside allowed":
cwd = t.TempDir()
}
t.Chdir(cwd)
@@ -107,7 +111,7 @@ func TestCreateTicketRejectsDefaultOutputSymlink(t *testing.T) {
t.Chdir(cwd)
executionID := t.Name()
setLocalFileAccess(executionID, false)
outside := filepath.Join(t.TempDir(), "ticket.ccache")
outside := filepath.Join(outsideAllowedDir(t), "ticket.ccache")
require.NoError(t, os.WriteFile(outside, []byte("unchanged"), 0600))
if err := os.Symlink(outside, filepath.Join(cwd, "Administrator.ccache")); err != nil {
t.Skipf("symlink unavailable: %v", err)
@@ -142,7 +146,7 @@ func TestCreateTicketRejectsDefaultOutputHardLink(t *testing.T) {
func TestCreateGoldenTicketDeniesDefaultOutputFromRuntime(t *testing.T) {
setTemplateDir(t)
cwd := t.TempDir()
cwd := outsideAllowedDir(t)
t.Chdir(cwd)
executionID := t.Name()
setLocalFileAccess(executionID, false)
@@ -177,6 +181,27 @@ func TestBuildConfigPreservesIdentity(t *testing.T) {
require.Equal(t, req.Domain, cfg.Domain)
}
// outsideAllowedDir returns a new directory outside the file allowlist. The
// temp dir is always allowlisted, so t.TempDir() cannot be used for this.
func outsideAllowedDir(t *testing.T) string {
t.Helper()
home, err := os.UserHomeDir()
if err != nil || home == "" {
t.Skip("no home directory to create a path outside the allowlist")
}
absHome, err := filepath.Abs(home)
require.NoError(t, err)
absTemp, err := filepath.Abs(os.TempDir())
require.NoError(t, err)
if absHome == absTemp || strings.HasPrefix(absHome+string(os.PathSeparator), absTemp+string(os.PathSeparator)) {
t.Skip("$HOME is under the temp root, so it is already allowlisted")
}
dir, err := os.MkdirTemp(home, "nuclei-krbforge-outside-*")
require.NoError(t, err)
t.Cleanup(func() { _ = os.RemoveAll(dir) })
return dir
}
func setTemplateDir(t *testing.T) string {
t.Helper()
templatesDir := t.TempDir()
+4 -4
View File
@@ -110,7 +110,7 @@ func NewClient(call goja.ConstructorCall, runtime *goja.Runtime) *goja.Object {
if u.Path == "" || u.Path == "/" {
u.Path = "/var/run/slapd/ldapi"
}
conn, err = dialers.Fastdialer.Dial(dialCtx, "unix", u.Path)
conn, err = protocolstate.DialAllowedWithExecutionID(dialCtx, executionId, "unix", u.Path)
c.nj.HandleError(err, "failed to connect to ldap server")
} else {
switch u.Scheme {
@@ -124,12 +124,12 @@ func NewClient(call goja.ConstructorCall, runtime *goja.Runtime) *goja.Object {
if port == "" {
port = ldap.DefaultLdapPort
}
conn, err = dialers.Fastdialer.Dial(dialCtx, "udp", net.JoinHostPort(host, port))
conn, err = protocolstate.DialAllowedWithExecutionID(dialCtx, executionId, "udp", net.JoinHostPort(host, port))
case "ldap":
if port == "" {
port = ldap.DefaultLdapPort
}
conn, err = dialers.Fastdialer.Dial(dialCtx, "tcp", net.JoinHostPort(host, port))
conn, err = protocolstate.DialAllowedWithExecutionID(dialCtx, executionId, "tcp", net.JoinHostPort(host, port))
case "ldaps":
if port == "" {
port = ldap.DefaultLdapsPort
@@ -138,7 +138,7 @@ func NewClient(call goja.ConstructorCall, runtime *goja.Runtime) *goja.Object {
if c.cfg.ServerName != "" {
serverName = c.cfg.ServerName
}
conn, err = dialers.Fastdialer.DialTLSWithConfig(dialCtx, "tcp", net.JoinHostPort(host, port),
conn, err = protocolstate.DialTLSAllowedWithExecutionID(dialCtx, executionId, "tcp", net.JoinHostPort(host, port),
&tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS10, ServerName: serverName})
}
default:
+5 -6
View File
@@ -91,7 +91,10 @@ type (
// log(to_json(info));
// ```
func (c *MSSQLClient) FingerprintMssql(ctx context.Context, host string, port int) (MSSQLInfo, error) {
executionId := ctx.Value("executionId").(string)
executionId := protocolstate.ExecutionIDFromContext(ctx)
if executionId == "" {
return MSSQLInfo{}, fmt.Errorf("mssql: missing executionId")
}
return memoizedfingerprintMssql(ctx, executionId, host, port)
}
@@ -109,12 +112,8 @@ func fingerprintMssql(ctx context.Context, executionId string, host string, port
if !protocolstate.IsHostAllowed(executionId, host) {
return info, protocolstate.ErrHostDenied.Msgf(host)
}
dialer := protocolstate.GetDialersWithId(executionId)
if dialer == nil {
return info, fmt.Errorf("dialers not initialized for %s", executionId)
}
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port)))
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port)))
if err != nil {
return info, err
}
+1 -1
View File
@@ -189,5 +189,5 @@ func mssqlConnString(target, username, password, dbName string) string {
url.PathEscape(username),
url.PathEscape(password),
target,
url.QueryEscape(dbName))
protocolstate.SanitizeMSSQLDatabaseName(dbName))
}
+10 -6
View File
@@ -10,6 +10,7 @@ import (
"github.com/go-sql-driver/mysql"
"github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
)
type (
@@ -131,12 +132,7 @@ func fingerprintMySQL(ctx context.Context, executionId string, host string, port
// host is not valid according to network policy
return info, protocolstate.ErrHostDenied.Msgf(host)
}
dialer := protocolstate.GetDialersWithId(executionId)
if dialer == nil {
return MySQLInfo{}, fmt.Errorf("dialers not initialized for %s", executionId)
}
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port)))
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port)))
if err != nil {
return info, err
}
@@ -220,6 +216,14 @@ func (c *MySQLClient) ExecuteQueryWithOpts(ctx context.Context, opts MySQLOption
return nil, err
}
// allowAllFiles is always stripped from the DSN; LOAD DATA LOCAL INFILE
// only works for allowlisted files the query names.
release, err := protocolstate.RegisterMySQLLocalInfiles(&types.Options{ExecutionId: executionId}, dsn, query)
if err != nil {
return nil, err
}
defer release()
db, err := openDB(executionId, dsn)
if err != nil {
return nil, err
+3 -18
View File
@@ -8,7 +8,7 @@ import (
"net/url"
"strings"
"github.com/go-sql-driver/mysql"
_ "github.com/go-sql-driver/mysql"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
)
@@ -76,24 +76,9 @@ func BuildDSN(opts MySQLOptions) (string, error) {
return dsn.String(), nil
}
// sandboxDSN enforces the local file access sandbox on a MySQL DSN. The
// driver's allowAllFiles option lets a malicious server read any local file
// off the host via LOAD DATA LOCAL INFILE, so it is only honored when -lfa is
// enabled, mirroring the fs.ReadFile restriction.
func sandboxDSN(dsn string, lfaAllowed bool) (string, error) {
cfg, err := mysql.ParseDSN(dsn)
if err != nil {
return "", err
}
if cfg.AllowAllFiles && !lfaAllowed {
cfg.AllowAllFiles = false
}
return cfg.FormatDSN(), nil
}
// openDB opens a sandboxed MySQL connection from dsn.
func openDB(executionId, dsn string) (*sql.DB, error) {
dsn, err := sandboxDSN(dsn, protocolstate.IsLfaAllowed(&types.Options{ExecutionId: executionId}))
opts := &types.Options{ExecutionId: executionId}
dsn, err := protocolstate.SanitizeMySQLDSN(dsn, opts)
if err != nil {
return nil, err
}
+8 -6
View File
@@ -4,12 +4,14 @@ import (
"testing"
"github.com/go-sql-driver/mysql"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/stretchr/testify/require"
)
func TestSandboxDSN(t *testing.T) {
t.Run("strips allowAllFiles when lfa disabled", func(t *testing.T) {
got, err := sandboxDSN("root:x@nucleitcp(127.0.0.1:3306)/?allowAllFiles=true", false)
got, err := protocolstate.SanitizeMySQLDSN("root:x@nucleitcp(127.0.0.1:3306)/?allowAllFiles=true", &types.Options{})
require.NoError(t, err)
cfg, err := mysql.ParseDSN(got)
@@ -17,17 +19,17 @@ func TestSandboxDSN(t *testing.T) {
require.False(t, cfg.AllowAllFiles)
})
t.Run("keeps allowAllFiles when lfa enabled", func(t *testing.T) {
got, err := sandboxDSN("root:x@nucleitcp(127.0.0.1:3306)/?allowAllFiles=true", true)
t.Run("strips allowAllFiles when lfa enabled", func(t *testing.T) {
got, err := protocolstate.SanitizeMySQLDSN("root:x@nucleitcp(127.0.0.1:3306)/?allowAllFiles=true", &types.Options{AllowLocalFileAccess: true})
require.NoError(t, err)
cfg, err := mysql.ParseDSN(got)
require.NoError(t, err)
require.True(t, cfg.AllowAllFiles)
require.False(t, cfg.AllowAllFiles, "-lfa must never disable the allowlist")
})
t.Run("leaves dsn without allowAllFiles untouched", func(t *testing.T) {
got, err := sandboxDSN("root:x@nucleitcp(127.0.0.1:3306)/", false)
got, err := protocolstate.SanitizeMySQLDSN("root:x@nucleitcp(127.0.0.1:3306)/", &types.Options{})
require.NoError(t, err)
cfg, err := mysql.ParseDSN(got)
@@ -36,7 +38,7 @@ func TestSandboxDSN(t *testing.T) {
})
t.Run("errors on invalid dsn", func(t *testing.T) {
_, err := sandboxDSN("::not-a-dsn::", false)
_, err := protocolstate.SanitizeMySQLDSN("::not-a-dsn::", &types.Options{})
require.Error(t, err)
})
}
+3 -12
View File
@@ -192,13 +192,13 @@ func Dial(ctx context.Context, protocol, address string) (net.Conn, error) {
if !protocolstate.IsHostAllowed(executionId, dialTargetHost(address)) {
return nil, fmt.Errorf("host %s is not allowed by network policy", address)
}
return dialHTTPProxy(ctx, dialer.Fastdialer.Dial, proxyURL, address, getTimeoutFromContext(ctx))
return dialHTTPProxy(ctx, protocolstate.DialAllowed, proxyURL, address, getTimeoutFromContext(ctx))
default:
return nil, fmt.Errorf("unsupported proxy scheme %q in %s (expected http or https)", parsed.Scheme, redactProxyURL(proxyURL))
}
}
return dialer.Fastdialer.Dial(ctx, protocol, address)
return protocolstate.DialAllowed(ctx, protocol, address)
}
// Open opens a new connection to the address with a timeout.
@@ -233,7 +233,6 @@ func OpenTLS(ctx context.Context, protocol, address string) (*NetConn, error) {
c.ServerName = host
config = c
}
if proxyURLFromContext(ctx) != "" {
conn, err := Dial(ctx, protocol, address)
if err != nil {
@@ -247,15 +246,7 @@ func OpenTLS(ctx context.Context, protocol, address string) (*NetConn, error) {
return &NetConn{conn: tlsConn, timeout: timeout}, nil
}
executionId, err := executionIDFromContext(ctx)
if err != nil {
return nil, err
}
dialer := protocolstate.GetDialersWithId(executionId)
if dialer == nil {
return nil, fmt.Errorf("dialers not initialized for %s", executionId)
}
conn, err := dialer.Fastdialer.DialTLSWithConfig(ctx, protocol, address, config)
conn, err := protocolstate.DialTLSAllowed(ctx, protocol, address, config)
if err != nil {
return nil, err
}
+5 -52
View File
@@ -5,15 +5,14 @@ import (
"database/sql"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"time"
"github.com/praetorian-inc/fingerprintx/pkg/plugins"
"github.com/praetorian-inc/fingerprintx/pkg/plugins/services/oracledb"
"github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
goora "github.com/sijms/go-ora/v2"
)
@@ -56,14 +55,9 @@ func (c *OracleClient) IsOracle(ctx context.Context, host string, port int) (IsO
// @memo
func isOracle(ctx context.Context, executionId string, host string, port int) (IsOracleResponse, error) {
resp := IsOracleResponse{}
dialer := protocolstate.GetDialersWithId(executionId)
if dialer == nil {
return IsOracleResponse{}, fmt.Errorf("dialers not initialized for %s", executionId)
}
timeout := 5 * time.Second
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
if err != nil {
return resp, err
}
@@ -86,7 +80,8 @@ func isOracle(ctx context.Context, executionId string, host string, port int) (I
}
func (c *OracleClient) oracleDbInstance(ctx context.Context, connStr string, executionId string) (*goora.OracleConnector, error) {
connStr, err := sandboxDSN(executionId, connStr)
opts := &types.Options{ExecutionId: executionId}
connStr, err := protocolstate.SanitizeOracleDSN(executionId, connStr, opts)
if err != nil {
return nil, err
}
@@ -107,48 +102,6 @@ func (c *OracleClient) oracleDbInstance(ctx context.Context, connStr string, exe
return c.connector, nil
}
func sandboxDSN(executionId string, dsn string) (string, error) {
parsed, err := url.Parse(dsn)
if err != nil {
return "", err
}
query := parsed.Query()
changed := false
for key, values := range query {
if !isOracleTracePathOption(key) {
continue
}
for i, value := range values {
if value == "" {
continue
}
normalized, err := protocolstate.NormalizePathWithExecutionId(executionId, value)
if err != nil {
return "", fmt.Errorf("oracle %s %q: %w", key, value, err)
}
values[i] = normalized
}
query[key] = values
changed = true
}
if !changed {
return dsn, nil
}
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
func isOracleTracePathOption(key string) bool {
switch strings.ToUpper(strings.TrimSpace(key)) {
case "TRACE FILE", "TRACE DIR", "TRACE FOLDER", "TRACE DIRECTORY":
return true
default:
return false
}
}
// Connect connects to an Oracle database
// @example
// ```javascript
+21 -10
View File
@@ -2,6 +2,7 @@ package oracle
import (
"context"
"os"
"path/filepath"
"testing"
@@ -12,6 +13,17 @@ import (
"github.com/stretchr/testify/require"
)
// outsideTemplatesPath returns an absolute path that is guaranteed to sit
// outside the sandbox allowlist on every OS. A Unix-style "/etc/..." literal is
// not absolute on Windows, so it would be resolved relative to the templates
// dir and fail with a "does not exist" error before the allowlist check ever
// runs. Rooting at the temp volume keeps it drive-qualified on Windows yet
// never inside the templates/temp/cwd roots.
func outsideTemplatesPath(name string) string {
root := filepath.VolumeName(os.TempDir()) + string(os.PathSeparator)
return filepath.Join(root, "nuclei-denied", name)
}
func TestSandboxDSNRejectsTraceFileOutsideTemplatesWithoutLFA(t *testing.T) {
templatesDir := t.TempDir()
restoreOracleTemplatesDir(t, templatesDir)
@@ -19,14 +31,14 @@ func TestSandboxDSNRejectsTraceFileOutsideTemplatesWithoutLFA(t *testing.T) {
executionID := t.Name()
protocolstate.SetLfaAllowed(&types.Options{ExecutionId: executionID, AllowLocalFileAccess: false})
traceFile := filepath.Join(t.TempDir(), "trace.log")
traceFile := outsideTemplatesPath("nuclei-oracle-trace-test.log")
dsn := go_ora.BuildUrl("127.0.0.1", 1521, "XE", "user", "pass", map[string]string{
"TRACE FILE": traceFile,
})
_, err := sandboxDSN(executionID, dsn)
_, err := protocolstate.SanitizeOracleDSN(executionID, dsn, &types.Options{ExecutionId: executionID, AllowLocalFileAccess: false})
require.Error(t, err)
require.Contains(t, err.Error(), "-lfa is not enabled")
require.Contains(t, err.Error(), "outside")
}
func TestConnectWithDSNRejectsTraceFileBeforeOracleOpen(t *testing.T) {
@@ -36,7 +48,7 @@ func TestConnectWithDSNRejectsTraceFileBeforeOracleOpen(t *testing.T) {
executionID := t.Name()
protocolstate.SetLfaAllowed(&types.Options{ExecutionId: executionID, AllowLocalFileAccess: false})
traceFile := filepath.Join(t.TempDir(), "trace.log")
traceFile := outsideTemplatesPath("nuclei-oracle-connect-trace-test.log")
dsn := go_ora.BuildUrl("127.0.0.1", 1521, "XE", "user", "pass", map[string]string{
"TRACE FILE": traceFile,
})
@@ -44,8 +56,7 @@ func TestConnectWithDSNRejectsTraceFileBeforeOracleOpen(t *testing.T) {
ctx := context.WithValue(context.Background(), "executionId", executionID) // nolint:staticcheck
_, err := (&OracleClient{}).ConnectWithDSN(ctx, dsn)
require.Error(t, err)
require.Contains(t, err.Error(), "-lfa is not enabled")
require.NoFileExists(t, traceFile)
require.Contains(t, err.Error(), "outside")
}
func TestSandboxDSNNormalizesTraceOptionsWithinTemplatesWithoutLFA(t *testing.T) {
@@ -62,7 +73,7 @@ func TestSandboxDSNNormalizesTraceOptionsWithinTemplatesWithoutLFA(t *testing.T)
"TRACE DIRECTORY": traceDir,
})
got, err := sandboxDSN(executionID, dsn)
got, err := protocolstate.SanitizeOracleDSN(executionID, dsn, &types.Options{ExecutionId: executionID, AllowLocalFileAccess: false})
require.NoError(t, err)
cfg, err := go_ora.ParseConfig(got)
@@ -71,19 +82,19 @@ func TestSandboxDSNNormalizesTraceOptionsWithinTemplatesWithoutLFA(t *testing.T)
require.Equal(t, traceDir, cfg.TraceDir)
}
func TestSandboxDSNAllowsTraceFileOutsideTemplatesWithLFA(t *testing.T) {
func TestSandboxDSNAllowsTraceFileWithinAllowedPathsWithLFA(t *testing.T) {
templatesDir := t.TempDir()
restoreOracleTemplatesDir(t, templatesDir)
executionID := t.Name()
protocolstate.SetLfaAllowed(&types.Options{ExecutionId: executionID, AllowLocalFileAccess: true})
traceFile := filepath.Join(t.TempDir(), "trace.log")
traceFile := filepath.Join(templatesDir, "trace.log")
dsn := go_ora.BuildUrl("127.0.0.1", 1521, "XE", "user", "pass", map[string]string{
"TRACE FILE": traceFile,
})
got, err := sandboxDSN(executionID, dsn)
got, err := protocolstate.SanitizeOracleDSN(executionID, dsn, &types.Options{ExecutionId: executionID, AllowLocalFileAccess: true})
require.NoError(t, err)
cfg, err := go_ora.ParseConfig(got)
+1 -10
View File
@@ -2,7 +2,6 @@ package oracle
import (
"context"
"fmt"
"net"
"time"
@@ -22,15 +21,7 @@ func (o *oracleCustomDialer) dialWithCtx(ctx context.Context, network, address s
if ctx == nil {
ctx = context.Background()
}
dialers := protocolstate.GetDialersWithId(o.executionId)
if dialers == nil {
return nil, fmt.Errorf("dialers not initialized for %s", o.executionId)
}
if !protocolstate.IsHostAllowed(o.executionId, address) {
// host is not valid according to network policy
return nil, protocolstate.ErrHostDenied.Msgf(address)
}
return dialers.Fastdialer.Dial(ctx, network, address)
return protocolstate.DialAllowedWithExecutionID(ctx, o.executionId, network, address)
}
func (o *oracleCustomDialer) Dial(network, address string) (net.Conn, error) {
+4 -6
View File
@@ -2,7 +2,6 @@ package pop3
import (
"context"
"fmt"
"net"
"strconv"
"time"
@@ -42,14 +41,13 @@ func IsPOP3(ctx context.Context, host string, port int) (IsPOP3Response, error)
// @memo
func isPoP3(ctx context.Context, executionId string, host string, port int) (IsPOP3Response, error) {
resp := IsPOP3Response{}
dialer := protocolstate.GetDialersWithId(executionId)
if dialer == nil {
return IsPOP3Response{}, fmt.Errorf("dialers not initialized for %s", executionId)
if !protocolstate.IsHostAllowed(executionId, host) {
// host is not valid according to network policy
return resp, protocolstate.ErrHostDenied.Msgf(host)
}
timeout := 5 * time.Second
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
if err != nil {
return resp, err
}
+3 -3
View File
@@ -50,7 +50,7 @@ func isPostgres(ctx context.Context, executionId string, host string, port int)
return false, fmt.Errorf("dialers not initialized for %s", executionId)
}
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", fmt.Sprintf("%s:%d", host, port))
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port)))
if err != nil {
return false, err
}
@@ -154,7 +154,7 @@ func buildPostgresConnURL(username, password, target, dbName, executionId string
return fmt.Sprintf("postgres://%s@%s/%s?%s",
url.UserPassword(username, password).String(),
target,
url.PathEscape(dbName),
protocolstate.SanitizePostgresDatabaseName(dbName),
values.Encode(),
)
}
@@ -210,7 +210,7 @@ func connect(ctx context.Context, executionId string, host string, port int, use
Password: password,
Database: dbName,
Dialer: func(dialCtx context.Context, network, addr string) (net.Conn, error) {
return dialer.Fastdialer.Dial(dialCtx, network, addr)
return protocolstate.DialAllowedWithExecutionID(dialCtx, executionId, network, addr)
},
IdleCheckFrequency: -1,
}).WithTimeout(10 * time.Second)
+4 -17
View File
@@ -46,13 +46,8 @@ func IsRDP(ctx context.Context, host string, port int) (IsRDPResponse, error) {
func isRDP(ctx context.Context, executionId string, host string, port int) (IsRDPResponse, error) {
resp := IsRDPResponse{}
dialer := protocolstate.GetDialersWithId(executionId)
if dialer == nil {
return IsRDPResponse{}, fmt.Errorf("dialers not initialized for %s", executionId)
}
timeout := 5 * time.Second
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", fmt.Sprintf("%s:%d", host, port))
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", fmt.Sprintf("%s:%d", host, port))
if err != nil {
return resp, err
}
@@ -105,12 +100,8 @@ func CheckRDPAuth(ctx context.Context, host string, port int) (CheckRDPAuthRespo
func checkRDPAuth(ctx context.Context, executionId string, host string, port int) (CheckRDPAuthResponse, error) {
resp := CheckRDPAuthResponse{}
dialer := protocolstate.GetDialersWithId(executionId)
if dialer == nil {
return CheckRDPAuthResponse{}, fmt.Errorf("dialers not initialized for %s", executionId)
}
timeout := 5 * time.Second
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", fmt.Sprintf("%s:%d", host, port))
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", fmt.Sprintf("%s:%d", host, port))
if err != nil {
return resp, err
}
@@ -193,10 +184,6 @@ func CheckRDPEncryption(ctx context.Context, host string, port int) (RDPEncrypti
// @memo
func checkRDPEncryption(ctx context.Context, executionId string, host string, port int) (RDPEncryptionResponse, error) {
dialer := protocolstate.GetDialersWithId(executionId)
if dialer == nil {
return RDPEncryptionResponse{}, fmt.Errorf("dialers not initialized for %s", executionId)
}
resp := RDPEncryptionResponse{}
defaultTimeout := 5 * time.Second
@@ -211,7 +198,7 @@ func checkRDPEncryption(ctx context.Context, executionId string, host string, po
for name, value := range protocols {
dialCtx, cancel := context.WithTimeout(ctx, defaultTimeout)
conn, err := dialer.Fastdialer.Dial(dialCtx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
conn, err := protocolstate.DialAllowedWithExecutionID(dialCtx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
if err != nil {
cancel()
continue
@@ -252,7 +239,7 @@ func checkRDPEncryption(ctx context.Context, executionId string, host string, po
for encryptionLevel, value := range ciphers {
dialCtx, cancel := context.WithTimeout(ctx, defaultTimeout)
conn, err := dialer.Fastdialer.Dial(dialCtx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
conn, err := protocolstate.DialAllowedWithExecutionID(dialCtx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
if err != nil {
cancel()
continue
+5 -10
View File
@@ -35,7 +35,7 @@ func getServerInfo(ctx context.Context, executionId string, host string, port in
return "", protocolstate.ErrHostDenied.Msgf(host)
}
// create a new client
client := newClient(fmt.Sprintf("%s:%d", host, port), "")
client := newClient(net.JoinHostPort(host, strconv.Itoa(port)), "")
defer func() {
_ = client.Close()
}()
@@ -73,7 +73,7 @@ func connect(ctx context.Context, executionId string, host string, port int, pas
return false, protocolstate.ErrHostDenied.Msgf(host)
}
// create a new client
client := newClient(fmt.Sprintf("%s:%d", host, port), password)
client := newClient(net.JoinHostPort(host, strconv.Itoa(port)), password)
defer func() {
_ = client.Close()
}()
@@ -109,7 +109,7 @@ func getServerInfoAuth(ctx context.Context, executionId string, host string, por
return "", protocolstate.ErrHostDenied.Msgf(host)
}
// create a new client
client := newClient(fmt.Sprintf("%s:%d", host, port), password)
client := newClient(net.JoinHostPort(host, strconv.Itoa(port)), password)
defer func() {
_ = client.Close()
}()
@@ -144,12 +144,7 @@ func IsAuthenticated(ctx context.Context, host string, port int) (bool, error) {
func isAuthenticated(ctx context.Context, executionId string, host string, port int) (bool, error) {
plugin := pluginsredis.REDISPlugin{}
timeout := 5 * time.Second
dialer := protocolstate.GetDialersWithId(executionId)
if dialer == nil {
return false, fmt.Errorf("dialers not initialized for %s", executionId)
}
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", fmt.Sprintf("%s:%d", host, port))
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port)))
if err != nil {
return false, err
}
@@ -180,7 +175,7 @@ func RunLuaScript(ctx context.Context, host string, port int, password string, s
return false, protocolstate.ErrHostDenied.Msgf(host)
}
// create a new client
client := newClient(fmt.Sprintf("%s:%d", host, port), password)
client := newClient(net.JoinHostPort(host, strconv.Itoa(port)), password)
defer func() {
_ = client.Close()
}()
+1 -1
View File
@@ -59,7 +59,7 @@ func connectWithFastDialer(ctx context.Context, executionId string, host string,
if dialer == nil {
return nil, fmt.Errorf("dialers not initialized for %s", executionId)
}
return dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
return protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
}
// IsRsync checks if a host is running a Rsync server.
+25 -11
View File
@@ -91,7 +91,10 @@ type (
// log(to_json(info));
// ```
func (c *SMBClient) ConnectSMBInfoMode(ctx context.Context, host string, port int) (*smb.SMBLog, error) {
executionId := ctx.Value("executionId").(string)
executionId := protocolstate.ExecutionIDFromContext(ctx)
if executionId == "" {
return nil, fmt.Errorf("smb: missing executionId in context")
}
return memoizedconnectSMBInfoMode(ctx, executionId, host, port)
}
@@ -101,13 +104,9 @@ func connectSMBInfoMode(ctx context.Context, executionId string, host string, po
// host is not valid according to network policy
return nil, protocolstate.ErrHostDenied.Msgf(host)
}
dialer := protocolstate.GetDialersWithId(executionId)
if dialer == nil {
return nil, fmt.Errorf("dialers not initialized for %s", executionId)
}
address := net.JoinHostPort(host, fmt.Sprintf("%d", port))
dialSMBInfo := func(ctx context.Context) (net.Conn, error) {
return dialer.Fastdialer.Dial(ctx, "tcp", address)
return protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", address)
}
conn, err := dialSMBInfo(ctx)
if err != nil {
@@ -148,7 +147,10 @@ func connectSMBInfoMode(ctx context.Context, executionId string, host string, po
// log(to_json(metadata));
// ```
func (c *SMBClient) ListSMBv2Metadata(ctx context.Context, host string, port int) (*plugins.ServiceSMB, error) {
executionId := ctx.Value("executionId").(string)
executionId := protocolstate.ExecutionIDFromContext(ctx)
if executionId == "" {
return nil, fmt.Errorf("smb: missing executionId in context")
}
if !protocolstate.IsHostAllowed(executionId, host) {
// host is not valid according to network policy
return nil, protocolstate.ErrHostDenied.Msgf(host)
@@ -203,7 +205,10 @@ func (c *SMBClient) Authenticate(ctx context.Context, host string, port int, use
//
// ```
func (c *SMBClient) ListShares(ctx context.Context, host string, port int, user, password string) ([]string, error) {
executionId := ctx.Value("executionId").(string)
executionId := protocolstate.ExecutionIDFromContext(ctx)
if executionId == "" {
return nil, fmt.Errorf("smb: missing executionId in context")
}
return memoizedlistShares(ctx, executionId, host, port, user, password)
}
@@ -218,7 +223,10 @@ func (c *SMBClient) ListShares(ctx context.Context, host string, port int, user,
// for (const e of entries) { log(e.Name + (e.IsDir ? '/' : '')); }
// ```
func (c *SMBClient) ListDir(ctx context.Context, host string, port int, user, password, share, dir string) ([]ShareEntry, error) {
executionId := ctx.Value("executionId").(string)
executionId := protocolstate.ExecutionIDFromContext(ctx)
if executionId == "" {
return nil, fmt.Errorf("smb: missing executionId in context")
}
return memoizedlistDir(ctx, executionId, host, port, user, password, share, dir)
}
@@ -232,7 +240,10 @@ func (c *SMBClient) ListDir(ctx context.Context, host string, port int, user, pa
// log(body);
// ```
func (c *SMBClient) ReadFile(ctx context.Context, host string, port int, user, password, share, filePath string) (string, error) {
executionId := ctx.Value("executionId").(string)
executionId := protocolstate.ExecutionIDFromContext(ctx)
if executionId == "" {
return "", fmt.Errorf("smb: missing executionId in context")
}
return memoizedreadFile(ctx, executionId, host, port, user, password, share, filePath)
}
@@ -246,7 +257,10 @@ func (c *SMBClient) ReadFile(ctx context.Context, host string, port int, user, p
// for (const e of tree) { log(e.Name); }
// ```
func (c *SMBClient) ListTree(ctx context.Context, host string, port int, user, password, share, dir string) ([]ShareEntry, error) {
executionId := ctx.Value("executionId").(string)
executionId := protocolstate.ExecutionIDFromContext(ctx)
if executionId == "" {
return nil, fmt.Errorf("smb: missing executionId in context")
}
return memoizedlistTree(ctx, executionId, host, port, user, password, share, dir)
}
+1 -6
View File
@@ -22,12 +22,7 @@ func collectSMBv2Metadata(ctx context.Context, executionId string, host string,
if timeout == 0 {
timeout = 5 * time.Second
}
dialer := protocolstate.GetDialersWithId(executionId)
if dialer == nil {
return nil, fmt.Errorf("dialers not initialized for %s", executionId)
}
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port)))
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port)))
if err != nil {
return nil, err
}
+5 -7
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"errors"
"fmt"
"net"
"strconv"
"time"
@@ -27,7 +26,10 @@ const (
// const isSMBGhost = smb.DetectSMBGhost('acme.com', 445);
// ```
func (c *SMBClient) DetectSMBGhost(ctx context.Context, host string, port int) (bool, error) {
executionId := ctx.Value("executionId").(string)
executionId := protocolstate.ExecutionIDFromContext(ctx)
if executionId == "" {
return false, errors.New("smb: missing executionId in context")
}
return memoizeddetectSMBGhost(ctx, executionId, host, port)
}
@@ -38,11 +40,7 @@ func detectSMBGhost(ctx context.Context, executionId string, host string, port i
return false, protocolstate.ErrHostDenied.Msgf(host)
}
addr := net.JoinHostPort(host, strconv.Itoa(port))
dialer := protocolstate.GetDialersWithId(executionId)
if dialer == nil {
return false, fmt.Errorf("dialers not initialized for %s", executionId)
}
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", addr)
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", addr)
if err != nil {
return false, err
+3 -8
View File
@@ -97,7 +97,7 @@ func (c *Client) IsSMTP() (SMTPResponse, error) {
return SMTPResponse{}, fmt.Errorf("dialers not initialized for %s", executionId)
}
conn, err := dialer.Fastdialer.Dial(c.nj.Context(), "tcp", net.JoinHostPort(c.host, c.port))
conn, err := protocolstate.DialAllowedWithExecutionID(c.nj.Context(), executionId, "tcp", net.JoinHostPort(c.host, c.port))
if err != nil {
return resp, err
}
@@ -141,7 +141,7 @@ func (c *Client) IsOpenRelay(msg *SMTPMessage) (bool, error) {
}
addr := net.JoinHostPort(c.host, c.port)
conn, err := dialer.Fastdialer.Dial(c.nj.Context(), "tcp", addr)
conn, err := protocolstate.DialAllowedWithExecutionID(c.nj.Context(), executionId, "tcp", addr)
if err != nil {
return false, err
}
@@ -219,12 +219,7 @@ func (c *Client) SendMail(msg *SMTPMessage) (bool, error) {
// past the network policy, so nothing could bound what it reads or close it on cancel.
func (c *Client) sendMail(auth smtp.Auth, msg *SMTPMessage) error {
executionId := c.nj.ExecutionId()
dialer := protocolstate.GetDialersWithId(executionId)
if dialer == nil {
return fmt.Errorf("dialers not initialized for %s", executionId)
}
conn, err := dialer.Fastdialer.Dial(c.nj.Context(), "tcp", net.JoinHostPort(c.host, c.port))
conn, err := protocolstate.DialAllowedWithExecutionID(c.nj.Context(), executionId, "tcp", net.JoinHostPort(c.host, c.port))
if err != nil {
return err
}
+4 -4
View File
@@ -88,7 +88,7 @@ func isTelnet(ctx context.Context, executionId string, host string, port int) (I
return IsTelnetResponse{}, fmt.Errorf("dialers not initialized for %s", executionId)
}
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
if err != nil {
return resp, err
}
@@ -132,7 +132,7 @@ func (c *TelnetClient) Connect(ctx context.Context, host string, port int, usern
}
// Create TCP connection
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
if err != nil {
return false, err
}
@@ -180,7 +180,7 @@ func (c *TelnetClient) Info(ctx context.Context, host string, port int) (TelnetI
return TelnetInfoResponse{}, fmt.Errorf("dialers not initialized for %s", executionId)
}
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
if err != nil {
return TelnetInfoResponse{}, err
}
@@ -226,7 +226,7 @@ func (c *TelnetClient) GetTelnetNTLMInfo(ctx context.Context, host string, port
}
// Create TCP connection
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
if err != nil {
return nil, err
}
+2 -2
View File
@@ -68,7 +68,7 @@ func connect(ctx context.Context, executionId string, host string, port int, pas
return false, fmt.Errorf("dialers not initialized for %s", executionId)
}
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
if err != nil {
return false, err
}
@@ -132,7 +132,7 @@ func isVNC(ctx context.Context, executionId string, host string, port int) (IsVN
if dialer == nil {
return IsVNCResponse{}, fmt.Errorf("dialers not initialized for %s", executionId)
}
conn, err := dialer.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
conn, err := protocolstate.DialAllowedWithExecutionID(ctx, executionId, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
if err != nil {
return resp, err
}
+11 -19
View File
@@ -14,7 +14,6 @@ import (
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/gozero"
"github.com/projectdiscovery/gozero/sandbox"
gozerotypes "github.com/projectdiscovery/gozero/types"
"github.com/projectdiscovery/nuclei/v3/pkg/js/compiler"
"github.com/projectdiscovery/nuclei/v3/pkg/operators"
@@ -47,8 +46,11 @@ var (
)
type Sandbox struct {
// WorkingDir is kept for backwards-compatible template parsing, but it is
// intentionally ignored by the gozero sandbox (Docker or native). Letting
// templates choose host/container workdirs reintroduces path-policy footguns.
WorkingDir string `yaml:"working-dir,omitempty" json:"working-dir,omitempty" jsonschema:"title=working-dir,description=Working directory"`
Image string `yaml:"image,omitempty" json:"image,omitempty" jsonschema:"title=image,description=Image"`
Image string `yaml:"image,omitempty" json:"image,omitempty" jsonschema:"title=image,description=Docker image for sandboxed code execution"`
}
// Request is a request for the SSL protocol
@@ -266,18 +268,7 @@ func (request *Request) ExecuteWithResults(input *contextargs.Context, dynamicVa
defer cancel()
// Note: we use contextutil despite the fact that gozero accepts context as argument
gOutput, err := contextutil.ExecFuncWithTwoReturns(ctx, func() (*gozerotypes.Result, error) {
if request.useSandbox() {
return request.gozero.EvalWithVirtualEnv(
ctx, gozero.VirtualEnvDocker,
request.src,
metaSrc,
&sandbox.DockerConfiguration{
WorkingDir: request.Sandbox.WorkingDir,
Image: request.Sandbox.Image,
},
)
}
return request.gozero.Eval(ctx, request.src, metaSrc)
return request.evalCode(ctx, metaSrc)
})
if gOutput == nil {
// write error to stderr buff
@@ -312,7 +303,12 @@ func (request *Request) ExecuteWithResults(input *contextargs.Context, dynamicVa
sb.WriteString(interpretEnvVars(gOutput.Command, allvars))
sb.WriteString("\n")
fmt.Fprintf(sb, "\n%v\n%v\n%v\n", dashes, "Command Output:", dashes)
sb.WriteString(gOutput.DebugData.String())
if gOutput.DebugData != nil {
sb.WriteString(gOutput.DebugData.String())
} else {
sb.WriteString(gOutput.Stdout.String())
sb.WriteString(gOutput.Stderr.String())
}
sb.WriteString("\n")
sb.WriteString("[WRN] Command Output here is stdout+sterr, in response variables they are separate (use -v -svd flags for more details)")
return sb.String()
@@ -505,10 +501,6 @@ func (r *Request) UpdateOptions(opts *protocols.ExecutorOptions) {
r.options.ApplyNewEngineOptions(opts)
}
func (r *Request) useSandbox() bool {
return r.Sandbox != nil && r.Sandbox.Image != ""
}
// sanitizeEnvValue removes NUL bytes, which are invalid in a subprocess
// environment entry, from a value.
func sanitizeEnvValue(value string) string {
+2
View File
@@ -36,6 +36,7 @@ func executeRequest(t *testing.T, request *Request, info *testutils.TemplateInfo
// other tests and make them order-dependent.
options := testutils.DefaultOptions.Copy()
options.EnableCodeTemplates = true
options.DisableSandbox = true
testutils.Init(options)
executerOpts := testutils.NewMockExecuterOptions(options, info)
@@ -122,6 +123,7 @@ func executeCodeProtocolWithCallbackOverride(t *testing.T, configure func(*types
// code templates are gated behind -code and signature verification, so
// enable both for this helper the same way executeRequest does.
options.EnableCodeTemplates = true
options.DisableSandbox = true
options.InteractionsCoolDownPeriod = 0
testutils.Init(options)
t.Cleanup(func() {
+106
View File
@@ -0,0 +1,106 @@
package code
import (
"context"
"errors"
"runtime"
"sync/atomic"
"time"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/gozero"
"github.com/projectdiscovery/gozero/confine"
gozerotypes "github.com/projectdiscovery/gozero/types"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/sandbox"
"github.com/projectdiscovery/utils/errkit"
)
// ErrNoCodeSandbox is returned when code templates cannot run confined: Docker
// is not reachable and no native sandbox works on this host. It fails closed;
// the template never runs on the bare host unless the user passes -no-sandbox.
var ErrNoCodeSandbox = errkit.New(noCodeSandboxMessage)
const noCodeSandboxMessage = "code template sandbox unavailable: docker is not reachable and no native sandbox works on this host (bubblewrap on linux, sandbox-exec on macos); use -no-sandbox to run code templates without a sandbox"
// dockerUnavailable latches once Docker reports it cannot confine, so later
// executions go straight to the native sandbox instead of probing a dead
// daemon on every request.
var dockerUnavailable atomic.Bool
// noSandboxReported makes the fail-closed error visible once at error level:
// per-request execution errors are only logged as warnings, which the
// default log level hides.
var noSandboxReported atomic.Bool
// evalCode runs the code template confined. Docker is preferred because the
// payload then sees only the image, not the host filesystem. When Docker is
// not reachable, the gozero native backend of the OS runs it (bubblewrap on
// Linux, Seatbelt on macOS): fresh workspace, no network, scrubbed
// environment, and the home directories and runtime sockets are hidden. When
// neither works, it fails closed.
func (request *Request) evalCode(ctx context.Context, input *gozero.Source) (*gozerotypes.Result, error) {
if request.options.Options.DisableSandbox {
return request.gozero.Eval(ctx, request.src, input)
}
if !dockerUnavailable.Load() {
result, err := request.gozero.EvalWithVirtualEnv(ctx, request.confinementPolicy(confine.BackendDocker), request.src, input)
if err == nil || !errors.Is(err, confine.ErrConfinementUnavailable) {
return result, err
}
if dockerUnavailable.CompareAndSwap(false, true) {
gologger.Verbose().Msgf("docker sandbox unavailable for code templates, using native sandbox: %v", err)
}
}
result, err := request.gozero.EvalWithVirtualEnv(ctx, request.confinementPolicy(confine.BackendAuto), request.src, input)
if err != nil && errors.Is(err, confine.ErrConfinementUnavailable) {
reason := err.Error()
if runtime.GOOS == "linux" && sandbox.Enforced() {
// Landlock forbids mount(2) in this process and its children, and
// bubblewrap needs mounts to build the sandbox.
reason += " (bubblewrap cannot run under the landlock process sandbox)"
}
if noSandboxReported.CompareAndSwap(false, true) {
gologger.Error().Msgf("%s (%s)", noCodeSandboxMessage, reason)
}
return nil, errkit.Wrap(ErrNoCodeSandbox, reason)
}
return result, err
}
// confinementPolicy returns the hardened gozero policy for backend. The
// gozero defaults are the hardened setting: no network, all capabilities
// dropped, no-new-privileges, read-only rootfs, resource limits, hidden home
// directories and a scrubbed environment.
func (request *Request) confinementPolicy(backend confine.Backend) *confine.Policy {
policy := confine.DefaultPolicy()
policy.Backend = backend
if backend == confine.BackendDocker {
// Run the payload as the unprivileged nobody user (65534) inside the
// container, so a kernel/runc container escape maps to an unprivileged
// host uid rather than root. The native backends keep the caller's uid,
// so the workspace stays owned by it.
policy.RunAsUID = 65534
policy.RunAsGID = 65534
// NOTE(max-hardening): Docker+runc shares the host kernel, so this
// closes the application-level escape surface (no shell injection, no
// unconfined fallback, deny-by-default, non-root) but not a
// kernel/runc 0-day escape. For hostile multi-tenant use, run the
// daemon on a kernel-isolating runtime (gVisor runsc or Kata microVM).
if request.Sandbox != nil && request.Sandbox.Image != "" {
policy.DockerImage = request.Sandbox.Image
}
}
if timeout := request.codeTimeout(); timeout > 0 {
policy.Timeout = timeout
}
return &policy
}
func (request *Request) codeTimeout() time.Duration {
if request.options == nil || request.options.Options == nil {
return 0
}
return time.Duration(request.options.Options.GetTimeouts().CodeExecutionTimeout)
}
+174
View File
@@ -0,0 +1,174 @@
//go:build linux || darwin
package code
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/projectdiscovery/gozero/confine"
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
"github.com/projectdiscovery/nuclei/v3/pkg/model"
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/stretchr/testify/require"
)
func dockerSandboxOrSkip(t *testing.T) {
t.Helper()
c, err := confine.New(&confine.Policy{Backend: confine.BackendDocker})
if err != nil {
t.Skipf("docker-backed gozero confinement unavailable: %v", err)
}
require.NoError(t, c.Close())
}
func executeSandboxedRequest(t *testing.T, request *Request) output.InternalEvent {
t.Helper()
dockerSandboxOrSkip(t)
options := testutils.DefaultOptions.Copy()
options.EnableCodeTemplates = true
options.DisableSandbox = false
testutils.Init(options)
t.Cleanup(func() { testutils.Cleanup(options) })
info := &testutils.TemplateInfo{
ID: "code-gozero-sandbox",
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
}
executerOpts := testutils.NewMockExecuterOptions(options, info)
executerOpts.Verified = true
require.NoError(t, request.Compile(executerOpts))
var gotEvent output.InternalEvent
ctxArgs := contextargs.NewWithInput(context.Background(), "")
require.NoError(t, request.ExecuteWithResults(ctxArgs, nil, nil, func(event *output.InternalWrappedEvent) {
gotEvent = event.InternalEvent
}))
return gotEvent
}
func TestCodeProtocolGozeroSandboxExecution(t *testing.T) {
gotEvent := executeSandboxedRequest(t, &Request{
Engine: []string{"sh"},
Source: "echo gozero-sandbox-ok",
})
require.Equal(t, "gozero-sandbox-ok", gotEvent["response"])
}
func TestCodeProtocolConfinementPolicyIsHardened(t *testing.T) {
request := &Request{
Sandbox: &Sandbox{
WorkingDir: "/tmp/ignored",
Image: "python:3.14",
},
}
options := testutils.DefaultOptions.Copy()
options.GetTimeouts().CodeExecutionTimeout = 3 * time.Second
request.options = testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{})
policy := request.confinementPolicy(confine.BackendDocker)
require.Equal(t, confine.BackendDocker, policy.Backend)
require.Equal(t, "python:3.14", policy.DockerImage)
require.False(t, policy.AllowNetwork)
require.False(t, policy.WritableRootfs)
require.False(t, policy.AllowCapabilities)
require.False(t, policy.AllowNewPrivileges)
require.Empty(t, policy.DockerUser)
require.Equal(t, 65534, policy.RunAsUID, "payload must run as a non-root uid")
require.Equal(t, 65534, policy.RunAsGID, "payload must run as a non-root gid")
require.Equal(t, 3*time.Second, policy.Timeout)
require.Empty(t, policy.Workspace, "template working-dir must not become a host workspace")
native := request.confinementPolicy(confine.BackendAuto)
require.Equal(t, confine.BackendAuto, native.Backend)
require.False(t, native.AllowNetwork)
require.False(t, native.AllowCapabilities)
require.Empty(t, native.Tmp, "no shared writable temp dir for the payload")
require.Empty(t, native.ReadOnlyPaths, "no home path is exposed")
require.Equal(t, 3*time.Second, native.Timeout)
}
// TestCodeProtocolNativeFallbackConfinesPayload forces the Docker-unreachable
// path and runs a real payload through the gozero native backend (Seatbelt or
// bubblewrap): it can write its workspace, and it can neither write the home
// directory, read files under it (such as ~/.ssh) nor reach the network.
func TestCodeProtocolNativeFallbackConfinesPayload(t *testing.T) {
if c, err := confine.New(&confine.Policy{Backend: confine.BackendAuto}); err != nil {
t.Skipf("native sandbox unavailable: %v", err)
} else {
require.NoError(t, c.Close())
}
prev := dockerUnavailable.Swap(true)
t.Cleanup(func() { dockerUnavailable.Store(prev) })
home, err := os.UserHomeDir()
require.NoError(t, err)
canaryDir, err := os.MkdirTemp(home, ".nuclei-native-canary-*")
require.NoError(t, err)
t.Cleanup(func() { _ = os.RemoveAll(canaryDir) })
secret := "NUCLEI_NATIVE_SECRET"
require.NoError(t, os.WriteFile(filepath.Join(canaryDir, "secret"), []byte(secret), 0o600))
homeWrite := filepath.Join(canaryDir, "written")
options := testutils.DefaultOptions.Copy()
options.EnableCodeTemplates = true
options.DisableSandbox = false
testutils.Init(options)
t.Cleanup(func() { testutils.Cleanup(options) })
request := &Request{
Engine: []string{"sh"},
Source: fmt.Sprintf(`echo ws > "$HOME/ws-file" && cat "$HOME/ws-file"
echo pwned > %[1]q 2>/dev/null; test -f %[1]q && echo HOME_WRITE_OK || echo HOME_WRITE_DENIED
cat %[2]q 2>/dev/null || echo HOME_READ_DENIED
ls %[3]q >/dev/null 2>&1 && echo SSH_READ_OK || echo SSH_READ_DENIED
(exec 3<>/dev/tcp/1.1.1.1/80) 2>/dev/null && echo NET_OK || echo NET_DENIED`, homeWrite, filepath.Join(canaryDir, "secret"), filepath.Join(home, ".ssh")),
}
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: "code-native-sandbox",
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
executerOpts.Verified = true
require.NoError(t, request.Compile(executerOpts))
var gotEvent output.InternalEvent
require.NoError(t, request.ExecuteWithResults(contextargs.NewWithInput(context.Background(), ""), nil, nil, func(event *output.InternalWrappedEvent) {
gotEvent = event.InternalEvent
}))
response, _ := gotEvent["response"].(string)
require.Contains(t, response, "ws\n")
require.Contains(t, response, "HOME_WRITE_DENIED")
require.Contains(t, response, "HOME_READ_DENIED")
require.Contains(t, response, "SSH_READ_DENIED")
require.NotContains(t, response, secret)
require.NoFileExists(t, homeWrite)
}
func TestCodeProtocolGozeroSandboxDeniesHostFileRead(t *testing.T) {
secret := "NUCLEI_HOST_SECRET_DO_NOT_LEAK"
hostFile := filepath.Join(t.TempDir(), "host-secret")
require.NoError(t, os.WriteFile(hostFile, []byte(secret), 0o600))
gotEvent := executeSandboxedRequest(t, &Request{
Engine: []string{"sh"},
Source: fmt.Sprintf("cat %q 2>/dev/null || true", hostFile),
})
response, _ := gotEvent["response"].(string)
stderr, _ := gotEvent["stderr"].(string)
require.NotContains(t, response+stderr, secret)
}
func TestCodeProtocolGozeroSandboxDeniesNetwork(t *testing.T) {
gotEvent := executeSandboxedRequest(t, &Request{
Engine: []string{"sh"},
Source: "wget -q -T 5 -O - http://example.com >/dev/null 2>&1 && echo REACHED || echo BLOCKED",
})
require.Equal(t, "BLOCKED", gotEvent["response"])
}
+22
View File
@@ -1,8 +1,11 @@
package protocolinit
import (
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/js/compiler"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/sandbox"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/dns/dnsclientpool"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/signerpool"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/network/networkclientpool"
@@ -16,6 +19,16 @@ func Init(options *types.Options) error {
if err := protocolstate.Init(options); err != nil {
return err
}
// Landlock is process-wide and irreversible. Under `go test` package CWDs
// and fixture trees are not in AllowedFileRoots, so applying it would break
// unrelated unit tests that open relative paths. Sandbox package tests call
// Apply directly.
_ = sandbox.Apply(sandbox.Config{
AllowedRoots: protocolstate.SandboxFileRoots(options),
OwnedRoots: protocolstate.SandboxOwnedFileRoots(options),
Disabled: !ProcessSandboxEnabled(options) || testing.Testing(),
IncludeRuntime: true,
})
if err := dnsclientpool.Init(options); err != nil {
return err
}
@@ -34,6 +47,15 @@ func Init(options *types.Options) error {
return nil
}
// ProcessSandboxEnabled reports whether Init applies the process-wide OS
// filesystem sandbox. It is opt-in through EnableProcessSandbox, which the
// nuclei CLI sets and SDK callers must request explicitly, because the sandbox
// confines the whole host process and cannot be undone. -no-sandbox
// (DisableSandbox) always turns it off.
func ProcessSandboxEnabled(options *types.Options) bool {
return options != nil && options.EnableProcessSandbox && !options.DisableSandbox
}
func Close(executionId string) {
protocolstate.Close(executionId)
}
@@ -0,0 +1,35 @@
package protocolinit
import (
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
)
func TestProcessSandboxEnabled(t *testing.T) {
tests := []struct {
name string
options *types.Options
want bool
}{
{name: "nil options", options: nil, want: false},
{name: "sdk default", options: &types.Options{}, want: false},
{name: "cli default", options: &types.Options{EnableProcessSandbox: true}, want: true},
{name: "cli no-sandbox", options: &types.Options{EnableProcessSandbox: true, DisableSandbox: true}, want: false},
{name: "disable without enable", options: &types.Options{DisableSandbox: true}, want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := ProcessSandboxEnabled(test.options); got != test.want {
t.Fatalf("ProcessSandboxEnabled() = %v, want %v", got, test.want)
}
})
}
}
func TestProcessSandboxEnabledSurvivesCopy(t *testing.T) {
options := &types.Options{EnableProcessSandbox: true}
if !ProcessSandboxEnabled(options.Copy()) {
t.Fatal("Options.Copy dropped EnableProcessSandbox")
}
}
@@ -0,0 +1,196 @@
package protocolstate
import (
"os"
"path/filepath"
"github.com/go-rod/rod/lib/launcher"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
filepathutil "github.com/projectdiscovery/nuclei/v3/pkg/utils/filepath"
fileutil "github.com/projectdiscovery/utils/file"
)
// AllowedFileRoots returns filesystem roots that template execution may access.
// Templates dir and the per-scan temp dir are always included. When -lfa is
// enabled the current working directory and any configured AllowedPaths are
// added as well. The config dir is never included: it holds the template
// signing key and reporting secrets.
func AllowedFileRoots(options *types.Options) []string {
roots := make([]string, 0, 6)
if templateDir := config.DefaultConfig.GetTemplateDir(); templateDir != "" {
roots = append(roots, canonicalRoot(templateDir))
}
// Only the per-scan temp dir nuclei created is allowlisted, never the
// shared system temp dir: on Linux that is /tmp, which holds other users'
// and other programs' files.
if options != nil {
if tempDir := GetTemporaryDirectory(options.ExecutionId); tempDir != "" {
roots = append(roots, canonicalRoot(tempDir))
}
}
// StoreResponseDir defaults to a relative "output" folder, so it is only a
// real root once response storing is actually requested. Adding it
// unconditionally would make the sandbox create an unused output dir.
if options != nil && options.StoreResponse && options.StoreResponseDir != "" {
roots = append(roots, canonicalRoot(options.StoreResponseDir))
}
if options == nil || !IsLfaAllowed(options) {
return uniqueRoots(roots)
}
if cwd, err := os.Getwd(); err == nil && cwd != "" {
roots = append(roots, canonicalRoot(cwd))
}
if options != nil {
// Merge the paths passed directly on options with any recorded for this
// execution id, so execution-id-only callers (that build a bare
// *types.Options with just ExecutionId) still honour --allowed-paths.
allowed := append([]string(nil), options.AllowedPaths...)
allowed = append(allowed, GetAllowedPaths(options.ExecutionId)...)
for _, allowedPath := range allowed {
if allowedPath == "" {
continue
}
roots = append(roots, canonicalRoot(allowedPath))
}
}
return uniqueRoots(roots)
}
// SandboxFileRoots returns the roots the OS-level sandbox must leave usable.
// It is deliberately wider than AllowedFileRoots: landlock restricts the whole
// process, so it also has to cover the paths nuclei itself was told to read and
// write (target lists, outputs, exports, resume state, template paths) plus the
// working directory. Template code does not gain access to any of these, since
// javascript/code file access is still checked against AllowedFileRoots.
func SandboxFileRoots(options *types.Options) []string {
roots := AllowedFileRoots(options)
// nuclei itself keeps working files in the system temp dir (the per-scan
// dir, the headless browser profile, the dedupe db, update locks), so
// the process sandbox must keep it usable. Templates still cannot reach
// it: their access is checked against AllowedFileRoots.
if tempDir := os.TempDir(); tempDir != "" {
roots = append(roots, canonicalRoot(tempDir))
}
if options == nil {
return uniqueRoots(roots)
}
if cwd, err := os.Getwd(); err == nil && cwd != "" {
roots = append(roots, canonicalRoot(cwd))
}
// Inputs only: these must already exist. Creating a missing one would
// fabricate an empty directory that shadows real template resolution.
inputs := []string{options.TargetsFilePath, options.Resume}
inputs = append(inputs, options.Templates...)
inputs = append(inputs, options.Workflows...)
for _, path := range inputs {
if path == "" {
continue
}
roots = append(roots, canonicalRoot(containingDir(path)))
}
return uniqueRoots(roots)
}
// SandboxOwnedFileRoots returns directories nuclei manages itself and must be
// able to create: its templates, config, state and cache dirs, the go-rod
// browser cache, and any output destination the user asked for. They are kept
// out of AllowedFileRoots so template code cannot read nuclei's own state.
func SandboxOwnedFileRoots(options *types.Options) []string {
roots := make([]string, 0, 8)
for _, dir := range []string{
config.DefaultConfig.GetTemplateDir(),
config.DefaultConfig.GetConfigDir(),
config.DefaultConfig.GetStateDir(),
config.DefaultConfig.GetCacheDir(),
launcher.DefaultBrowserDir,
} {
if dir != "" {
roots = append(roots, canonicalRoot(dir))
}
}
if options == nil {
return uniqueRoots(roots)
}
if options.Headless {
roots = append(roots, browserProfileRoots()...)
}
outputs := []string{
options.Output,
options.JSONExport,
options.JSONLExport,
options.MarkdownExportDirectory,
options.SarifExport,
}
if options.StoreResponse {
outputs = append(outputs, options.StoreResponseDir)
}
for _, path := range outputs {
if path == "" {
continue
}
roots = append(roots, canonicalRoot(containingDir(path)))
}
return uniqueRoots(roots)
}
// browserProfileRoots returns the profile directories chrome falls back to when
// it is started without -user-data-dir. go-rod does exactly that when it checks
// whether a cached browser still works, so denying these makes chrome's crash
// handler abort, go-rod conclude the browser is broken, and re-download it on
// every run. Only the landlock sandbox consumes these paths.
func browserProfileRoots() []string {
configDir, err := os.UserConfigDir()
if err != nil || configDir == "" {
return nil
}
roots := make([]string, 0, 2)
for _, name := range []string{"chromium", "google-chrome"} {
roots = append(roots, canonicalRoot(filepath.Join(configDir, name)))
}
return roots
}
// containingDir returns path when it is an existing directory and its parent
// otherwise, so a not-yet-created output file still grants its target folder.
func containingDir(path string) string {
if info, err := os.Stat(path); err == nil && info.IsDir() {
return path
}
return filepath.Dir(path)
}
func canonicalRoot(path string) string {
abs, err := filepath.Abs(path)
if err != nil {
abs = filepath.Clean(path)
}
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
return filepath.Clean(resolved)
}
return filepath.Clean(abs)
}
func uniqueRoots(roots []string) []string {
seen := make(map[string]struct{}, len(roots))
out := make([]string, 0, len(roots))
for _, root := range roots {
if root == "" {
continue
}
if _, ok := seen[root]; ok {
continue
}
seen[root] = struct{}{}
out = append(out, root)
}
return out
}
func isPathAllowed(options *types.Options, cleanedPath string) bool {
return filepathutil.IsPathWithinAnyDirectory(cleanedPath, AllowedFileRoots(options)...)
}
func resolveAndCleanPath(filePath, baseDir string) (string, error) {
return fileutil.ResolveNClean(filePath, baseDir)
}
@@ -0,0 +1,259 @@
package protocolstate_test
import (
"os"
"path/filepath"
"testing"
"github.com/projectdiscovery/goflags"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/stretchr/testify/require"
)
func TestAllowedFileRootsAlwaysIncludesTemplatesAndScanTemp(t *testing.T) {
templatesDir := t.TempDir()
restoreTemplatesDir(t, templatesDir)
scanTemp := t.TempDir()
protocolstate.SetTemporaryDirectory(t.Name(), scanTemp)
t.Cleanup(func() { protocolstate.SetTemporaryDirectory(t.Name(), "") })
roots := protocolstate.AllowedFileRoots(&types.Options{ExecutionId: t.Name()})
require.NotEmpty(t, roots)
require.True(t, rootListContains(roots, templatesDir), "templates dir must be in allowlist: %v", roots)
require.True(t, rootListContains(roots, scanTemp), "per-scan temp dir must be in allowlist: %v", roots)
}
// The shared system temp dir (/tmp on Linux) holds other users' and other
// programs' files, so templates must not reach it. Only the per-scan dir is
// allowed, while the process sandbox still keeps the system temp dir usable.
func TestSystemTempDirIsNotTemplateReadable(t *testing.T) {
templatesDir := t.TempDir()
restoreTemplatesDir(t, templatesDir)
scanTemp := t.TempDir()
protocolstate.SetTemporaryDirectory(t.Name(), scanTemp)
t.Cleanup(func() { protocolstate.SetTemporaryDirectory(t.Name(), "") })
opts := &types.Options{ExecutionId: t.Name()}
roots := protocolstate.AllowedFileRoots(opts)
require.False(t, rootListContains(roots, os.TempDir()), "system temp dir must not be in allowlist: %v", roots)
sibling, err := os.MkdirTemp("", "nuclei-shared-tmp-*")
require.NoError(t, err)
t.Cleanup(func() { _ = os.RemoveAll(sibling) })
shared := filepath.Join(sibling, "other-user-file")
require.NoError(t, os.WriteFile(shared, []byte("x"), 0o600))
_, err = protocolstate.NormalizePath(opts, shared)
require.ErrorIs(t, err, protocolstate.ErrPathDenied)
inScan := filepath.Join(scanTemp, "scan-file")
require.NoError(t, os.WriteFile(inScan, []byte("x"), 0o600))
_, err = protocolstate.NormalizePath(opts, inScan)
require.NoError(t, err)
require.True(t, rootListContains(protocolstate.SandboxFileRoots(opts), os.TempDir()), "process sandbox must keep the system temp dir usable")
protocolstate.SetTemporaryDirectory(t.Name(), "")
require.False(t, rootListContains(protocolstate.AllowedFileRoots(opts), scanTemp), "cleared per-scan temp dir must leave the allowlist")
}
func TestAllowedFileRootsWithLFAIncludesCWD(t *testing.T) {
templatesDir := t.TempDir()
restoreTemplatesDir(t, templatesDir)
cwd, err := os.Getwd()
require.NoError(t, err)
roots := protocolstate.AllowedFileRoots(&types.Options{
ExecutionId: t.Name(),
AllowLocalFileAccess: true,
})
require.True(t, rootListContains(roots, cwd), "cwd must be in allowlist when -lfa is enabled")
}
func TestAllowedFileRootsWithAllowedPaths(t *testing.T) {
templatesDir := t.TempDir()
restoreTemplatesDir(t, templatesDir)
extraDir := t.TempDir()
roots := protocolstate.AllowedFileRoots(&types.Options{
ExecutionId: t.Name(),
AllowLocalFileAccess: true,
AllowedPaths: goflags.StringSlice{extraDir},
})
require.True(t, rootListContains(roots, extraDir))
}
// The OS sandbox restricts the whole process, so it must keep nuclei's own
// CLI inputs and outputs reachable even though templates may not read them.
// Without this, `nuclei -l targets.txt` fails with permission denied.
func TestSandboxFileRootsCoversCLIPathsButAllowlistDoesNot(t *testing.T) {
templatesDir := t.TempDir()
restoreTemplatesDir(t, templatesDir)
workDir := t.TempDir()
targets := filepath.Join(workDir, "targets.txt")
require.NoError(t, os.WriteFile(targets, []byte("scanme.sh\n"), 0o600))
outputDir := t.TempDir()
opts := &types.Options{
ExecutionId: t.Name(),
TargetsFilePath: targets,
Output: filepath.Join(outputDir, "results.txt"),
}
sandboxRoots := protocolstate.SandboxFileRoots(opts)
require.True(t, rootListContains(sandboxRoots, workDir), "targets file dir must be sandbox-reachable: %v", sandboxRoots)
ownedRoots := protocolstate.SandboxOwnedFileRoots(opts)
require.True(t, rootListContains(ownedRoots, outputDir), "output dir must be sandbox-reachable even before the file exists: %v", ownedRoots)
// the template-facing allowlist stays narrow
allowlist := protocolstate.AllowedFileRoots(opts)
require.False(t, rootListContains(allowlist, workDir), "templates must not gain access to the targets dir: %v", allowlist)
}
// Template updates write templates.json under the state dir, so the sandbox
// must cover it while templates themselves stay locked out of it.
func TestSandboxFileRootsCoversStateAndCacheDirs(t *testing.T) {
templatesDir := t.TempDir()
restoreTemplatesDir(t, templatesDir)
opts := &types.Options{ExecutionId: t.Name()}
roots := protocolstate.SandboxOwnedFileRoots(opts)
allowlist := protocolstate.AllowedFileRoots(opts)
for _, dir := range []string{config.DefaultConfig.GetConfigDir(), config.DefaultConfig.GetStateDir(), config.DefaultConfig.GetCacheDir()} {
require.NotEmpty(t, dir)
require.True(t, rootListContains(roots, dir), "sandbox must own %s: %v", dir, roots)
require.False(t, rootListContains(allowlist, dir), "templates must not reach %s: %v", dir, allowlist)
}
}
// The config dir holds the template signing private key, so a template must
// not read it even when nothing else denies the path.
func TestNormalizePathDeniesSigningKeysDir(t *testing.T) {
templatesDir := t.TempDir()
restoreTemplatesDir(t, templatesDir)
keyPath := filepath.Join(config.DefaultConfig.GetKeysDir(), "nuclei-user-private-key.pem")
_, err := protocolstate.NormalizePath(&types.Options{ExecutionId: t.Name()}, keyPath)
require.ErrorIs(t, err, protocolstate.ErrPathDenied)
}
// A missing -t path must never be treated as an owned root. Creating it would
// leave an empty directory that template resolution then prefers over the real
// templates dir, which silently loads zero templates.
func TestSandboxOwnedRootsExcludeTemplateInputPaths(t *testing.T) {
templatesDir := t.TempDir()
restoreTemplatesDir(t, templatesDir)
opts := &types.Options{
ExecutionId: t.Name(),
Templates: goflags.StringSlice{"http/cves/"},
}
owned := protocolstate.SandboxOwnedFileRoots(opts)
for _, root := range owned {
require.NotContains(t, root, filepath.Join("http", "cves"), "template input path must not be an owned root: %v", owned)
}
}
func TestSandboxOwnedRootsCoverBrowserProfileOnlyForHeadless(t *testing.T) {
templatesDir := t.TempDir()
restoreTemplatesDir(t, templatesDir)
configDir, err := os.UserConfigDir()
require.NoError(t, err)
profile := filepath.Join(configDir, "chromium")
// go-rod starts a cached browser without -user-data-dir to check it still
// works, so chrome falls back here. Denying it makes go-rod re-download the
// browser on every run.
headless := protocolstate.SandboxOwnedFileRoots(&types.Options{ExecutionId: t.Name(), Headless: true})
require.Contains(t, headless, profile, "headless runs must own the browser profile dir: %v", headless)
plain := protocolstate.SandboxOwnedFileRoots(&types.Options{ExecutionId: t.Name()})
require.NotContains(t, plain, profile, "non-headless runs must not create a browser profile dir: %v", plain)
}
func TestSandboxFileRootsIncludesWorkingDirectory(t *testing.T) {
templatesDir := t.TempDir()
restoreTemplatesDir(t, templatesDir)
cwd, err := os.Getwd()
require.NoError(t, err)
roots := protocolstate.SandboxFileRoots(&types.Options{ExecutionId: t.Name()})
require.True(t, rootListContains(roots, cwd), "cwd must be sandbox-reachable without -lfa: %v", roots)
}
func TestNormalizePathRejectsTraversalOutsideTemplates(t *testing.T) {
templatesDir := t.TempDir()
restoreTemplatesDir(t, templatesDir)
opts := &types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: false}
_, err := protocolstate.NormalizePath(opts, "/etc/passwd")
require.Error(t, err)
}
func TestNormalizePathAllowsFileInsideTemplatesViaRelativePath(t *testing.T) {
templatesDir := t.TempDir()
payload := filepath.Join(templatesDir, "helpers", "payload.txt")
require.NoError(t, os.MkdirAll(filepath.Dir(payload), 0o700))
require.NoError(t, os.WriteFile(payload, []byte("secret"), 0o600))
restoreTemplatesDir(t, templatesDir)
opts := &types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: false}
got, err := protocolstate.NormalizePath(opts, "helpers/payload.txt")
require.NoError(t, err)
require.Contains(t, got, "payload.txt")
}
func TestNormalizePathLFADoesNotBypassAbsoluteOutsideRoots(t *testing.T) {
templatesDir := t.TempDir()
restoreTemplatesDir(t, templatesDir)
opts := &types.Options{
ExecutionId: t.Name(),
AllowLocalFileAccess: true,
}
_, err := protocolstate.NormalizePath(opts, "/etc/passwd")
require.Error(t, err)
}
func TestNormalizePathAllowedPathsGrantAccessWithLFA(t *testing.T) {
templatesDir := t.TempDir()
restoreTemplatesDir(t, templatesDir)
grantedDir := t.TempDir()
secretPath := filepath.Join(grantedDir, "secret.txt")
require.NoError(t, os.WriteFile(secretPath, []byte("x"), 0o600))
opts := &types.Options{
ExecutionId: t.Name(),
AllowLocalFileAccess: true,
AllowedPaths: goflags.StringSlice{grantedDir},
}
got, err := protocolstate.NormalizePath(opts, secretPath)
require.NoError(t, err)
require.Equal(t, secretPath, got)
}
func rootListContains(roots []string, target string) bool {
cleanTarget, err := filepath.EvalSymlinks(filepath.Clean(target))
if err != nil {
cleanTarget = filepath.Clean(target)
}
for _, root := range roots {
cleanRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil {
cleanRoot = filepath.Clean(root)
}
if cleanRoot == cleanTarget {
return true
}
}
return false
}
@@ -0,0 +1,65 @@
package protocolstate
import (
"context"
"crypto/tls"
"fmt"
"net"
)
// ExecutionIDFromContext returns the nuclei execution id stored on ctx.
func ExecutionIDFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
if v := ctx.Value("executionId"); v != nil {
if id, ok := v.(string); ok {
return id
}
}
return ""
}
// DialAllowed dials through the execution-scoped fastdialer which enforces
// network policy. All javascript/code native modules must use this helper
// instead of calling net.Dial or Fastdialer directly.
func DialAllowed(ctx context.Context, network, address string) (net.Conn, error) {
executionID := ExecutionIDFromContext(ctx)
if executionID == "" {
return nil, fmt.Errorf("protocolstate: refusing to dial %s/%s without executionId", network, address)
}
return DialAllowedWithExecutionID(ctx, executionID, network, address)
}
// DialAllowedWithExecutionID dials using a known execution id.
func DialAllowedWithExecutionID(ctx context.Context, executionID, network, address string) (net.Conn, error) {
if executionID == "" {
return nil, fmt.Errorf("protocolstate: refusing to dial %s/%s without executionId", network, address)
}
dialer := GetDialersWithId(executionID)
if dialer == nil || dialer.Fastdialer == nil {
return nil, fmt.Errorf("protocolstate: dialers not initialized for %q", executionID)
}
return dialer.Fastdialer.Dial(ctx, network, address)
}
// DialTLSAllowed dials TLS through the execution-scoped fastdialer.
func DialTLSAllowed(ctx context.Context, network, address string, config *tls.Config) (net.Conn, error) {
executionID := ExecutionIDFromContext(ctx)
if executionID == "" {
return nil, fmt.Errorf("protocolstate: refusing to dial %s/%s without executionId", network, address)
}
return DialTLSAllowedWithExecutionID(ctx, executionID, network, address, config)
}
// DialTLSAllowedWithExecutionID dials TLS using a known execution id.
func DialTLSAllowedWithExecutionID(ctx context.Context, executionID, network, address string, config *tls.Config) (net.Conn, error) {
if executionID == "" {
return nil, fmt.Errorf("protocolstate: refusing to dial %s/%s without executionId", network, address)
}
dialer := GetDialersWithId(executionID)
if dialer == nil || dialer.Fastdialer == nil {
return nil, fmt.Errorf("protocolstate: dialers not initialized for %q", executionID)
}
return dialer.Fastdialer.DialTLSWithConfig(ctx, network, address, config)
}
@@ -0,0 +1,89 @@
package protocolstate_test
import (
"context"
"strings"
"testing"
"time"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/gologger/levels"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/stretchr/testify/require"
)
func initTestDialers(t *testing.T, opts *types.Options) context.Context {
t.Helper()
gologger.DefaultLogger.SetMaxLevel(levels.LevelSilent)
if opts.ExecutionId == "" {
opts.ExecutionId = t.Name()
}
require.NoError(t, protocolstate.Init(opts))
t.Cleanup(func() { protocolstate.Close(opts.ExecutionId) })
ctx := context.WithValue(context.Background(), "executionId", opts.ExecutionId) //nolint:staticcheck
return ctx
}
func TestDialAllowedWithExecutionIDRequiresInitializedDialers(t *testing.T) {
ctx := context.Background()
_, err := protocolstate.DialAllowedWithExecutionID(ctx, "missing", "tcp", "127.0.0.1:80")
require.Error(t, err)
require.Contains(t, err.Error(), "dialers not initialized")
}
func TestDialAllowedRejectsExcludedHost(t *testing.T) {
opts := &types.Options{
ExecutionId: "dial-exclude-" + t.Name(),
ExcludeTargets: []string{"203.0.113.10"},
}
ctx := initTestDialers(t, opts)
dialCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
_, err := protocolstate.DialAllowed(dialCtx, "tcp", "203.0.113.10:445")
require.Error(t, err)
}
func TestDialAllowedAllowsNonExcludedHost(t *testing.T) {
opts := &types.Options{
ExecutionId: "dial-allow-" + t.Name(),
ExcludeTargets: []string{"203.0.113.10"},
}
ctx := initTestDialers(t, opts)
dialCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
// 127.0.0.1:1 should fail fast with connection refused, not policy denial.
_, err := protocolstate.DialAllowed(dialCtx, "tcp", "127.0.0.1:1")
require.Error(t, err)
require.NotContains(t, strings.ToLower(err.Error()), "network policy")
}
func TestIsHostAllowedFailsClosedWithInitializedDialersButExcludedHost(t *testing.T) {
opts := &types.Options{
ExecutionId: "host-deny-" + t.Name(),
ExcludeTargets: []string{"203.0.113.10"},
}
_ = initTestDialers(t, opts)
require.False(t, protocolstate.IsHostAllowed(opts.ExecutionId, "203.0.113.10"))
}
func TestIsHostAllowedAllowsNonExcludedHost(t *testing.T) {
opts := &types.Options{
ExecutionId: "host-allow-" + t.Name(),
ExcludeTargets: []string{"203.0.113.10"},
}
_ = initTestDialers(t, opts)
require.True(t, protocolstate.IsHostAllowed(opts.ExecutionId, "127.0.0.1"))
}
func TestExecutionIDFromContext(t *testing.T) {
require.Equal(t, "", protocolstate.ExecutionIDFromContext(context.Background()))
require.Equal(t, "", protocolstate.ExecutionIDFromContext(nil)) //nolint:staticcheck // deliberately asserting nil-context handling
ctx := context.WithValue(context.Background(), "executionId", "abc") //nolint:staticcheck
require.Equal(t, "abc", protocolstate.ExecutionIDFromContext(ctx))
}
+169
View File
@@ -0,0 +1,169 @@
package protocolstate
import (
"fmt"
"net/url"
"path/filepath"
"regexp"
"strings"
"sync"
"github.com/go-sql-driver/mysql"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
)
// SanitizeMySQLDSN enforces filesystem sandbox rules on a MySQL DSN. It always
// removes allowAllFiles: that option lets the server read any client file
// through LOAD DATA LOCAL INFILE, and -lfa must never disable the allowlist.
// RegisterMySQLLocalInfiles allows single allowlisted files instead.
func SanitizeMySQLDSN(dsn string, options *types.Options) (string, error) {
cfg, err := mysql.ParseDSN(dsn)
if err != nil {
return "", err
}
cfg.AllowAllFiles = false
return cfg.FormatDSN(), nil
}
// mysqlLocalInfilePattern finds the file literal of LOAD DATA/XML LOCAL INFILE.
var mysqlLocalInfilePattern = regexp.MustCompile(`(?is)\bLOAD\s+(?:DATA|XML)\s+(?:(?:LOW_PRIORITY|CONCURRENT)\s+)?LOCAL\s+INFILE\s+(?:'((?:[^'\\]|\\.|'')*)'|"((?:[^"\\]|\\.|"")*)")`)
var (
mysqlLocalFilesMu sync.Mutex
mysqlLocalFiles = map[string]int{}
// registerMySQLLocalFile and deregisterMySQLLocalFile are the mysql
// driver's process-wide file registry, replaceable in tests.
registerMySQLLocalFile = mysql.RegisterLocalFile
deregisterMySQLLocalFile = mysql.DeregisterLocalFile
)
// RegisterMySQLLocalInfiles allows LOAD DATA LOCAL INFILE for the files that
// query names. It does so only when the DSN asks for local files
// (allowAllFiles=true), -lfa is enabled, and every file is an absolute path
// inside the filesystem allowlist; a file outside it is an error. Each file
// is registered with the mysql driver by its exact path, so the server can
// only request registered (allowlisted) files, never an arbitrary one. The
// caller must call the returned release func after the query.
func RegisterMySQLLocalInfiles(options *types.Options, dsn, query string) (func(), error) {
noop := func() {}
cfg, err := mysql.ParseDSN(dsn)
if err != nil {
return noop, err
}
if !cfg.AllowAllFiles || options == nil || !IsLfaAllowed(options) {
return noop, nil
}
var files []string
for _, match := range mysqlLocalInfilePattern.FindAllStringSubmatch(query, -1) {
path := unescapeMySQLString(match[1])
if match[1] == "" {
path = unescapeMySQLString(match[2])
}
if !filepath.IsAbs(path) {
return noop, fmt.Errorf("%w: LOAD DATA LOCAL INFILE path %q must be absolute", ErrPathDenied, path)
}
if _, err := NormalizePath(options, path); err != nil {
return noop, err
}
files = append(files, path)
}
if len(files) == 0 {
return noop, nil
}
mysqlLocalFilesMu.Lock()
for _, file := range files {
if mysqlLocalFiles[file] == 0 {
registerMySQLLocalFile(file)
}
mysqlLocalFiles[file]++
}
mysqlLocalFilesMu.Unlock()
var once sync.Once
return func() {
once.Do(func() {
mysqlLocalFilesMu.Lock()
defer mysqlLocalFilesMu.Unlock()
for _, file := range files {
mysqlLocalFiles[file]--
if mysqlLocalFiles[file] <= 0 {
delete(mysqlLocalFiles, file)
deregisterMySQLLocalFile(file)
}
}
})
}, nil
}
// unescapeMySQLString decodes the backslash and doubled-quote escapes of a
// MySQL string literal body.
func unescapeMySQLString(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if c == '\\' && i+1 < len(s) {
i++
b.WriteByte(s[i])
continue
}
if (c == '\'' || c == '"') && i+1 < len(s) && s[i+1] == c {
i++
}
b.WriteByte(c)
}
return b.String()
}
// SanitizeOracleDSN normalizes oracle trace-related DSN options through the
// filesystem allowlist broker.
func SanitizeOracleDSN(executionID, dsn string, options *types.Options) (string, error) {
parsed, err := url.Parse(dsn)
if err != nil {
return "", err
}
if options == nil {
options = &types.Options{ExecutionId: executionID}
}
query := parsed.Query()
changed := false
for key, values := range query {
if !isOracleTracePathOption(key) {
continue
}
for i, value := range values {
if value == "" {
continue
}
normalized, err := NormalizePath(options, value)
if err != nil {
return "", fmt.Errorf("oracle %s %q: %w", key, value, err)
}
values[i] = normalized
}
query[key] = values
changed = true
}
if !changed {
return dsn, nil
}
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
func isOracleTracePathOption(key string) bool {
switch strings.ToUpper(strings.TrimSpace(key)) {
case "TRACE FILE", "TRACE DIR", "TRACE FOLDER", "TRACE DIRECTORY":
return true
default:
return false
}
}
// SanitizeMSSQLDatabaseName escapes database names used in MSSQL connection strings.
func SanitizeMSSQLDatabaseName(dbName string) string {
return url.QueryEscape(dbName)
}
// SanitizePostgresDatabaseName escapes database names used in postgres URLs.
func SanitizePostgresDatabaseName(dbName string) string {
return url.PathEscape(dbName)
}
@@ -0,0 +1,193 @@
package protocolstate_test
import (
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/go-sql-driver/mysql"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
go_ora "github.com/sijms/go-ora/v2"
"github.com/stretchr/testify/require"
)
func TestSanitizeMySQLDSNStripsAllowAllFilesWithoutLFA(t *testing.T) {
got, err := protocolstate.SanitizeMySQLDSN(
"root:secret@nucleitcp(127.0.0.1:3306)/?allowAllFiles=true",
&types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: false},
)
require.NoError(t, err)
cfg, err := mysql.ParseDSN(got)
require.NoError(t, err)
require.False(t, cfg.AllowAllFiles)
}
func TestSanitizeMySQLDSNStripsAllowAllFilesWithLFA(t *testing.T) {
got, err := protocolstate.SanitizeMySQLDSN(
"root:secret@nucleitcp(127.0.0.1:3306)/?allowAllFiles=true",
&types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: true},
)
require.NoError(t, err)
cfg, err := mysql.ParseDSN(got)
require.NoError(t, err)
require.False(t, cfg.AllowAllFiles, "-lfa must never disable the allowlist")
}
func TestSanitizeMySQLDSNRejectsInvalidDSN(t *testing.T) {
_, err := protocolstate.SanitizeMySQLDSN("::not-a-dsn::", &types.Options{})
require.Error(t, err)
}
func TestSanitizeOracleDSNRejectsTraceFileOutsideAllowlist(t *testing.T) {
templatesDir := t.TempDir()
restoreTemplatesDir(t, templatesDir)
dsn := go_ora.BuildUrl("127.0.0.1", 1521, "XE", "user", "pass", map[string]string{
"TRACE FILE": "/etc/nuclei-oracle-trace.log",
})
_, err := protocolstate.SanitizeOracleDSN(t.Name(), dsn, &types.Options{
ExecutionId: t.Name(),
AllowLocalFileAccess: false,
})
require.Error(t, err)
}
func TestSanitizeOracleDSNNormalizesTraceFileInsideTemplates(t *testing.T) {
templatesDir := t.TempDir()
restoreTemplatesDir(t, templatesDir)
traceFile := filepath.Join(templatesDir, "trace.log")
dsn := go_ora.BuildUrl("127.0.0.1", 1521, "XE", "user", "pass", map[string]string{
"TRACE FILE": traceFile,
})
got, err := protocolstate.SanitizeOracleDSN(t.Name(), dsn, &types.Options{
ExecutionId: t.Name(),
AllowLocalFileAccess: false,
})
require.NoError(t, err)
cfg, err := go_ora.ParseConfig(got)
require.NoError(t, err)
require.Equal(t, traceFile, cfg.TraceFilePath)
}
func TestSanitizeOracleDSNNormalizesTraceDirectory(t *testing.T) {
templatesDir := t.TempDir()
restoreTemplatesDir(t, templatesDir)
traceDir := filepath.Join(templatesDir, "traces")
dsn := go_ora.BuildUrl("127.0.0.1", 1521, "XE", "user", "pass", map[string]string{
"TRACE DIRECTORY": traceDir,
})
got, err := protocolstate.SanitizeOracleDSN(t.Name(), dsn, &types.Options{
ExecutionId: t.Name(),
AllowLocalFileAccess: false,
})
require.NoError(t, err)
cfg, err := go_ora.ParseConfig(got)
require.NoError(t, err)
require.Equal(t, traceDir, cfg.TraceDir)
}
func TestSanitizeMSSQLDatabaseNameEscapesInjection(t *testing.T) {
malicious := "master;DROP TABLE users--"
got := protocolstate.SanitizeMSSQLDatabaseName(malicious)
require.NotEqual(t, malicious, got)
require.Equal(t, url.QueryEscape(malicious), got)
}
func TestSanitizePostgresDatabaseNameEscapesInjection(t *testing.T) {
malicious := "../../etc/passwd"
got := protocolstate.SanitizePostgresDatabaseName(malicious)
require.NotEqual(t, malicious, got)
require.Equal(t, url.PathEscape(malicious), got)
}
// recordMySQLRegistry swaps the mysql driver file registry for a recorder.
func recordMySQLRegistry(t *testing.T) map[string]bool {
t.Helper()
registered := map[string]bool{}
restore := protocolstate.SwapMySQLLocalFileRegistry(
func(path string) { registered[path] = true },
func(path string) { delete(registered, path) },
)
t.Cleanup(restore)
return registered
}
const mysqlLocalFilesDSN = "root:x@nucleitcp(127.0.0.1:3306)/?allowAllFiles=true"
// sqlPath escapes backslashes the way a MySQL string literal needs them, so
// Windows paths survive the round trip.
func sqlPath(p string) string {
return strings.ReplaceAll(p, `\`, `\\`)
}
func TestRegisterMySQLLocalInfilesAllowsOnlyAllowlistedFiles(t *testing.T) {
templatesDir := t.TempDir()
restoreTemplatesDir(t, templatesDir)
registered := recordMySQLRegistry(t)
opts := &types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: true}
inside := filepath.Join(templatesDir, "data.csv")
require.NoError(t, os.WriteFile(inside, []byte("1\n"), 0o600))
release, err := protocolstate.RegisterMySQLLocalInfiles(opts, mysqlLocalFilesDSN, "LOAD DATA LOCAL INFILE '"+sqlPath(inside)+"' INTO TABLE t")
require.NoError(t, err)
require.Equal(t, map[string]bool{inside: true}, registered)
release()
require.Empty(t, registered, "release must deregister the file")
outside := outsideHomePath(t, ".nuclei-mysql-infile-"+t.Name())
_, err = protocolstate.RegisterMySQLLocalInfiles(opts, mysqlLocalFilesDSN, `load data low_priority local infile "`+sqlPath(outside)+`" into table t`)
require.ErrorIs(t, err, protocolstate.ErrPathDenied)
require.Empty(t, registered)
_, err = protocolstate.RegisterMySQLLocalInfiles(opts, mysqlLocalFilesDSN, "LOAD DATA LOCAL INFILE 'relative.csv' INTO TABLE t")
require.ErrorIs(t, err, protocolstate.ErrPathDenied)
require.Empty(t, registered)
}
func TestRegisterMySQLLocalInfilesNeedsLFAAndDSNOptIn(t *testing.T) {
templatesDir := t.TempDir()
restoreTemplatesDir(t, templatesDir)
registered := recordMySQLRegistry(t)
inside := filepath.Join(templatesDir, "data.csv")
query := "LOAD DATA LOCAL INFILE '" + sqlPath(inside) + "' INTO TABLE t"
release, err := protocolstate.RegisterMySQLLocalInfiles(&types.Options{ExecutionId: t.Name()}, mysqlLocalFilesDSN, query)
require.NoError(t, err)
release()
require.Empty(t, registered, "nothing may be registered without -lfa")
release, err = protocolstate.RegisterMySQLLocalInfiles(&types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: true}, "root:x@nucleitcp(127.0.0.1:3306)/", query)
require.NoError(t, err)
release()
require.Empty(t, registered, "nothing may be registered when the DSN does not ask for local files")
}
func TestRegisterMySQLLocalInfilesRefcountsSharedFiles(t *testing.T) {
templatesDir := t.TempDir()
restoreTemplatesDir(t, templatesDir)
registered := recordMySQLRegistry(t)
opts := &types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: true}
inside := filepath.Join(templatesDir, "it's.csv")
query := `LOAD DATA LOCAL INFILE '` + sqlPath(filepath.Join(templatesDir, "it''s.csv")) + `' INTO TABLE t`
first, err := protocolstate.RegisterMySQLLocalInfiles(opts, mysqlLocalFilesDSN, query)
require.NoError(t, err)
second, err := protocolstate.RegisterMySQLLocalInfiles(opts, mysqlLocalFilesDSN, query)
require.NoError(t, err)
require.True(t, registered[inside], "escaped quote must decode to the real file name")
first()
first()
require.True(t, registered[inside], "a concurrent query still needs the file")
second()
require.Empty(t, registered)
}
@@ -0,0 +1,11 @@
package protocolstate
// SwapMySQLLocalFileRegistry replaces the mysql driver file registry hooks
// for a test and returns a func that restores them.
func SwapMySQLLocalFileRegistry(register, deregister func(string)) func() {
oldRegister, oldDeregister := registerMySQLLocalFile, deregisterMySQLLocalFile
registerMySQLLocalFile, deregisterMySQLLocalFile = register, deregister
return func() {
registerMySQLLocalFile, deregisterMySQLLocalFile = oldRegister, oldDeregister
}
}
+90 -17
View File
@@ -1,17 +1,36 @@
package protocolstate
import (
"errors"
"fmt"
"sync"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
filepathutil "github.com/projectdiscovery/nuclei/v3/pkg/utils/filepath"
"github.com/projectdiscovery/utils/errkit"
fileutil "github.com/projectdiscovery/utils/file"
mapsutil "github.com/projectdiscovery/utils/maps"
)
// ErrPathDenied is returned when a path is outside the filesystem allowlist.
var ErrPathDenied = errors.New("path denied by filesystem allowlist")
var (
// LfaAllowed means local file access is allowed
LfaAllowed *mapsutil.SyncLockMap[string, bool]
// allowedPathsByExec stores the -allowed-paths allowlist per execution id so
// callers that only have an execution id (NormalizePathWithExecutionId,
// fs.ReadFile, krbforge.normalizeOutputFile) still honour --allowed-paths
// even though they never see the full *types.Options. A plain guarded map is
// used because the value is a slice (not comparable, so unusable as a
// SyncLockMap value).
allowedPathsMu sync.RWMutex
allowedPathsByExec = map[string][]string{}
// tempDirByExec stores the per-scan temporary directory nuclei created for
// an execution id. It replaces the shared system temp dir in the template
// filesystem allowlist.
tempDirMu sync.RWMutex
tempDirByExec = map[string]string{}
)
func init() {
@@ -39,6 +58,63 @@ func IsLfaAllowed(options *types.Options) bool {
func SetLfaAllowed(options *types.Options) {
_ = LfaAllowed.Set(options.ExecutionId, options.AllowLocalFileAccess)
SetAllowedPaths(options)
}
// SetAllowedPaths records the --allowed-paths allowlist for an execution id so
// execution-id-only path checks can expand the allowlist the same way callers
// that pass full options do.
func SetAllowedPaths(options *types.Options) {
if options == nil || options.ExecutionId == "" {
return
}
paths := make([]string, 0, len(options.AllowedPaths))
for _, p := range options.AllowedPaths {
if p != "" {
paths = append(paths, p)
}
}
allowedPathsMu.Lock()
allowedPathsByExec[options.ExecutionId] = paths
allowedPathsMu.Unlock()
}
// GetAllowedPaths returns the --allowed-paths allowlist recorded for an
// execution id, or nil when none was stored.
func GetAllowedPaths(executionId string) []string {
if executionId == "" {
return nil
}
allowedPathsMu.RLock()
defer allowedPathsMu.RUnlock()
return allowedPathsByExec[executionId]
}
// SetTemporaryDirectory records the per-scan temporary directory nuclei
// created for an execution id. Templates may access it; the shared system
// temp dir (on Linux, /tmp) is not part of the template allowlist.
func SetTemporaryDirectory(executionId, dir string) {
if executionId == "" {
return
}
tempDirMu.Lock()
defer tempDirMu.Unlock()
if dir == "" {
delete(tempDirByExec, executionId)
return
}
tempDirByExec[executionId] = dir
}
// GetTemporaryDirectory returns the per-scan temporary directory recorded for
// an execution id, or an empty string when none was recorded.
func GetTemporaryDirectory(executionId string) string {
if executionId == "" {
return ""
}
tempDirMu.RLock()
defer tempDirMu.RUnlock()
return tempDirByExec[executionId]
}
func GetLfaAllowed(options *types.Options) bool {
@@ -54,29 +130,26 @@ func NormalizePathWithExecutionId(executionId string, filePath string) (string,
return NormalizePath(options, filePath)
}
// Normalizepath normalizes path and returns absolute path
// it returns error if path is not allowed
// this respects the sandbox rules and only loads files from
// allowed directories
// NormalizePath normalizes path and returns absolute path.
// It returns an error when the resolved path is outside the computed
// filesystem allowlist. -lfa expands the allowlist but never disables it.
func NormalizePath(options *types.Options, filePath string) (string, error) {
// TODO: this should be tied to executionID using *types.Options
if IsLfaAllowed(options) {
// if local file access is allowed, we can return the absolute path
return filePath, nil
if filePath == "" {
return "", errkit.New("empty file path is not allowed")
}
cleaned, err := fileutil.ResolveNClean(filePath, config.DefaultConfig.GetTemplateDir())
baseDir := config.DefaultConfig.GetTemplateDir()
cleaned, err := resolveAndCleanPath(filePath, baseDir)
if err != nil {
return "", errkit.Wrapf(err, "could not resolve and clean path %v", filePath)
}
// only allow files inside nuclei-templates directory
// even current working directory is not allowed
if filepathutil.IsPathWithinDirectory(cleaned, config.DefaultConfig.GetTemplateDir()) {
// reject hard-linked regular files, whose inode can alias content
// outside the templates directory.
if isPathAllowed(options, cleaned) {
if filepathutil.IsHardLinkedRegularFile(cleaned) {
return "", errkit.Newf("path %v denied (hard link)", filePath)
return "", fmt.Errorf("%w: path %v denied (hard link)", ErrPathDenied, filePath)
}
return cleaned, nil
}
return "", errkit.Newf("path %v is outside nuclei-template directory and -lfa is not enabled", filePath)
if options != nil && IsLfaAllowed(options) {
return "", fmt.Errorf("%w: path %v is outside allowed directories (use --allowed-paths to grant access)", ErrPathDenied, filePath)
}
return "", fmt.Errorf("%w: path %v is outside nuclei-template directory and -lfa is not enabled", ErrPathDenied, filePath)
}
@@ -0,0 +1,69 @@
package protocolstate_test
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/stretchr/testify/require"
)
// outsideAllowlistPath returns an absolute path outside the sandbox allowlist
// on every OS. "/etc/passwd" is not absolute on Windows and would resolve
// relative to the templates dir (failing with "does not exist" before the
// allowlist check); rooting at the temp volume keeps it drive-qualified yet
// outside the templates/temp/cwd roots.
func outsideAllowlistPath(name string) string {
root := filepath.VolumeName(os.TempDir()) + string(os.PathSeparator)
return filepath.Join(root, "nuclei-denied", name)
}
func TestNormalizePathRejectsOutsideAllowlistWithoutLFA(t *testing.T) {
templatesDir := t.TempDir()
old := config.DefaultConfig.TemplatesDirectory
config.DefaultConfig.SetTemplatesDir(templatesDir)
t.Cleanup(func() { config.DefaultConfig.SetTemplatesDir(old) })
opts := &types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: false}
_, err := protocolstate.NormalizePath(opts, outsideAllowlistPath("passwd"))
require.Error(t, err)
}
func TestNormalizePathAllowsTemplatesDir(t *testing.T) {
templatesDir := t.TempDir()
payloadPath := filepath.Join(templatesDir, "payload.txt")
require.NoError(t, os.WriteFile(payloadPath, []byte("ok"), 0o600))
old := config.DefaultConfig.TemplatesDirectory
config.DefaultConfig.SetTemplatesDir(templatesDir)
t.Cleanup(func() { config.DefaultConfig.SetTemplatesDir(old) })
opts := &types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: false}
got, err := protocolstate.NormalizePath(opts, payloadPath)
require.NoError(t, err)
require.Contains(t, got, templatesDir)
}
func TestNormalizePathLFADoesNotBypassAllowlist(t *testing.T) {
templatesDir := t.TempDir()
old := config.DefaultConfig.TemplatesDirectory
config.DefaultConfig.SetTemplatesDir(templatesDir)
t.Cleanup(func() { config.DefaultConfig.SetTemplatesDir(old) })
opts := &types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: true}
_, err := protocolstate.NormalizePath(opts, outsideAllowlistPath("passwd"))
require.Error(t, err)
require.Contains(t, err.Error(), "allowed directories")
}
func TestIsHostAllowedFailsClosedWithoutDialers(t *testing.T) {
require.False(t, protocolstate.IsHostAllowed("missing-execution-id", "127.0.0.1:80"))
}
func TestDialAllowedRequiresExecutionID(t *testing.T) {
_, err := protocolstate.DialAllowed(context.Background(), "tcp", "127.0.0.1:80")
require.Error(t, err)
}
@@ -0,0 +1,16 @@
package protocolstate
import (
"os"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
)
// ReadFileAllowed reads a file after enforcing filesystem allowlist rules.
func ReadFileAllowed(options *types.Options, filePath string) ([]byte, error) {
finalPath, err := NormalizePath(options, filePath)
if err != nil {
return nil, err
}
return os.ReadFile(finalPath)
}
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more