Compare commits

..
Author SHA1 Message Date
Mzack9999 40321dd68a Merge dev 2026-10-27 16:09:42 +01:00
Mzack9999 55d17f5a29 Pin baseline 2026-10-26 16:29:39 +01:00
Mzack9999 b7e66ae57c Fix test 2026-10-26 15:57:03 +01:00
Mzack9999 9aef358094 Score llm 2026-10-26 12:26:43 +01:00
Mzack9999 b62180c641 Merge dev 2026-10-26 12:04:12 +01:00
Mzack9999 1d3f1f5bc2 Gate interactsh 2026-10-26 11:32:06 +01:00
Mzack9999 4b3c1633ee Merge dev 2026-10-26 01:00:32 +01:00
Mzack9999 d2f862edf5 Merge origin 2026-10-26 00:33:21 +01:00
Mzack9999 717062fd0e fix confidence 2026-09-23 21:58:12 +02:00
Mzack9999 6c2ea4edcb merge dev 2026-09-23 20:23:17 +02:00
Mzack9999 1898b4aec4 merge dev 2026-09-14 22:34:30 +02:00
Mzack9999 e3864b60f2 Resolve conflicts 2026-07-15 19:02:06 +04:00
Mzack9999 187d855145 allowlist openvas 2026-06-26 01:48:22 +02:00
Mzack9999 1f276227ff report confidence 2026-06-26 01:31:23 +02:00
Mzack9999 e0fd39a862 protocol parts 2026-06-26 01:24:31 +02:00
Mzack9999 e0ed203765 negative evidence 2026-06-26 01:14:08 +02:00
Mzack9999 3ae786749d tune scoring 2026-06-26 01:08:50 +02:00
Mzack9999 b3881248ca refine scoring 2026-06-26 01:00:51 +02:00
Mzack9999 1ddd00efef baseline detection 2026-06-26 00:57:58 +02:00
Mzack9999 bc776d29a8 score confidence 2026-06-26 00:57:58 +02:00
144 changed files with 2379 additions and 6135 deletions

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@ It also specifies a `WorkflowLoader` interface that is used during workflow load
```go
type WorkflowLoader interface {
GetTemplatePathsByTags(tags []string, noValidate bool) []string
GetTemplatePathsByTags(tags []string) []string
GetTemplatePaths(templatesList []string, noValidate bool) []string
}
```
+3 -5
View File
@@ -145,12 +145,12 @@ TARGET:
-eh, -exclude-hosts string[] hosts to exclude to scan from the input list (ip, cidr, hostname)
-resume string resume scan from and save to specified file (clustering will be disabled)
-sa, -scan-all-ips scan all the IP's associated with dns record
-iv, -ip-version string[] IP version to scan of hostname (4,6); default uses dialer fallback, -sa scans all addresses
-iv, -ip-version string[] IP version to scan of hostname (4,6) - (default 4)
-dun, -disable-url-normalization keep targets exactly as supplied instead of collapsing urls that address the same resource
-mup, -max-urls-per-pattern int scan at most this many urls sharing one structural shape, e.g. /user/1 and /user/2 (0 to scan all)
TARGET-FORMAT:
-im, -input-mode string mode of input file (list, burp, har, jsonl, yaml, openapi, swagger, http) (default "list")
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger, http) (default "list")
-ro, -required-only use only required fields in input format when generating requests
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
@@ -177,7 +177,7 @@ TEMPLATES:
-egm, -enable-global-matchers enable loading global matchers templates
-file enable loading file templates
-llm enable llm matchers and extractors (semantic matching)
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (default "openai")
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter) (default "openai")
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
-llm-model string model used for semantic matching
-llm-timeout int time in seconds to wait for a single llm call (default 30)
@@ -221,7 +221,6 @@ OUTPUT:
-jle, -jsonl-export string file to export results in JSONL(ine) format
-pe, -pdf-export string file to export results in PDF format
-rd, -redact string[] redact given list of keys from query parameter, request header and body
-nrd, -no-redact disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted
CONFIGURATIONS:
-config string path to the nuclei configuration file
@@ -327,7 +326,6 @@ OPTIMIZATIONS:
-nh, -no-httpx disable httpx probing for non-url input
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
-ac, -auto-calibrate drop HTTP matches on a host's catch-all page (answers missing paths with 2xx/3xx)
HEADLESS:
-headless enable templates that require headless browser support (root user on Linux will disable sandbox)
+2 -3
View File
@@ -148,7 +148,7 @@ TARGET:
-iv, -ip-version string[] IP version to scan of hostname (4,6) - (default 4)
TARGET-FORMAT:
-im, -input-mode string mode of input file (list, burp, har, jsonl, yaml, openapi, swagger, http) (default "list")
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger, http) (default "list")
-ro, -required-only use only required fields in input format when generating requests
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
@@ -175,7 +175,7 @@ TEMPLATES:
-egm, -enable-global-matchers enable loading global matchers templates
-file enable loading file templates
-llm enable llm matchers and extractors (semantic matching)
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (default "openai")
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter) (default "openai")
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
-llm-model string model used for semantic matching
-llm-timeout int time in seconds to wait for a single llm call (default 30)
@@ -219,7 +219,6 @@ OUTPUT:
-jle, -jsonl-export string file to export results in JSONL(ine) format
-pe, -pdf-export string file to export results in PDF format
-rd, -redact string[] redact given list of keys from query parameter, request header and body
-nrd, -no-redact disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted
CONFIGURATIONS:
-config string path to the nuclei configuration file
+2 -3
View File
@@ -148,7 +148,7 @@ TARGET:
-iv, -ip-version string[] IP version to scan of hostname (4,6) - (default 4)
TARGET-FORMAT:
-im, -input-mode string mode of input file (list, burp, har, jsonl, yaml, openapi, swagger, http) (default "list")
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger, http) (default "list")
-ro, -required-only use only required fields in input format when generating requests
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
@@ -175,7 +175,7 @@ TEMPLATES:
-egm, -enable-global-matchers enable loading global matchers templates
-file enable loading file templates
-llm enable llm matchers and extractors (semantic matching)
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (default "openai")
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter) (default "openai")
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
-llm-model string model used for semantic matching
-llm-timeout int time in seconds to wait for a single llm call (default 30)
@@ -219,7 +219,6 @@ OUTPUT:
-jle, -jsonl-export string file to export results in JSONL(ine) format
-pe, -pdf-export string file to export results in PDF format
-rd, -redact string[] redact given list of keys from query parameter, request header and body
-nrd, -no-redact disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted
CONFIGURATIONS:
-config string path to the nuclei configuration file
+2 -3
View File
@@ -165,7 +165,7 @@ TARGET:
-mup, -max-urls-per-pattern int scan at most this many urls sharing one structural shape, e.g. /user/1 and /user/2 (0 to scan all)
TARGET-FORMAT:
-im, -input-mode string mode of input file (list, burp, har, jsonl, yaml, openapi, swagger, http) (default "list")
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger, http) (default "list")
-ro, -required-only use only required fields in input format when generating requests
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
@@ -192,7 +192,7 @@ TEMPLATES:
-egm, -enable-global-matchers enable loading global matchers templates
-file enable loading file templates
-llm enable llm matchers and extractors (semantic matching)
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (default "openai")
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter) (default "openai")
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
-llm-model string model used for semantic matching
-llm-timeout int time in seconds to wait for a single llm call (default 30)
@@ -236,7 +236,6 @@ OUTPUT:
-jle, -jsonl-export string file to export results in JSONL(ine) format
-pe, -pdf-export string file to export results in PDF format
-rd, -redact string[] redact given list of keys from query parameter, request header and body
-nrd, -no-redact disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted
CONFIGURATIONS:
-config string path to the nuclei configuration file
+2 -3
View File
@@ -148,7 +148,7 @@ TARGET:
-iv, -ip-version string[] IP version to scan of hostname (4,6) - (default 4)
TARGET-FORMAT:
-im, -input-mode string mode of input file (list, burp, har, jsonl, yaml, openapi, swagger, http) (default "list")
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger, http) (default "list")
-ro, -required-only use only required fields in input format when generating requests
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
@@ -175,7 +175,7 @@ TEMPLATES:
-egm, -enable-global-matchers enable loading global matchers templates
-file enable loading file templates
-llm enable llm matchers and extractors (semantic matching)
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (default "openai")
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter) (default "openai")
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
-llm-model string model used for semantic matching
-llm-timeout int time in seconds to wait for a single llm call (default 30)
@@ -219,7 +219,6 @@ OUTPUT:
-jle, -jsonl-export string file to export results in JSONL(ine) format
-pe, -pdf-export string file to export results in PDF format
-rd, -redact string[] redact given list of keys from query parameter, request header and body
-nrd, -no-redact disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted
CONFIGURATIONS:
-config string path to the nuclei configuration file
+2 -3
View File
@@ -148,7 +148,7 @@ TARGET:
-iv, -ip-version string[] IP version to scan of hostname (4,6) - (default 4)
TARGET-FORMAT:
-im, -input-mode string mode of input file (list, burp, har, jsonl, yaml, openapi, swagger, http) (default "list")
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger, http) (default "list")
-ro, -required-only use only required fields in input format when generating requests
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
@@ -175,7 +175,7 @@ TEMPLATES:
-egm, -enable-global-matchers enable loading global matchers templates
-file enable loading file templates
-llm enable llm matchers and extractors (semantic matching)
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (default "openai")
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter) (default "openai")
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
-llm-model string model used for semantic matching
-llm-timeout int time in seconds to wait for a single llm call (default 30)
@@ -219,7 +219,6 @@ OUTPUT:
-jle, -jsonl-export string file to export results in JSONL(ine) format
-pe, -pdf-export string file to export results in PDF format
-rd, -redact string[] redact given list of keys from query parameter, request header and body
-nrd, -no-redact disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted
CONFIGURATIONS:
-config string path to the nuclei configuration file
+2 -3
View File
@@ -148,7 +148,7 @@ TARGET:
-iv, -ip-version string[] IP version to scan of hostname (4,6) - (default 4)
TARGET-FORMAT:
-im, -input-mode string mode of input file (list, burp, har, jsonl, yaml, openapi, swagger, http) (default "list")
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger, http) (default "list")
-ro, -required-only use only required fields in input format when generating requests
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
@@ -175,7 +175,7 @@ TEMPLATES:
-egm, -enable-global-matchers enable loading global matchers templates
-file enable loading file templates
-llm enable llm matchers and extractors (semantic matching)
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (default "openai")
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter) (default "openai")
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
-llm-model string model used for semantic matching
-llm-timeout int time in seconds to wait for a single llm call (default 30)
@@ -219,7 +219,6 @@ OUTPUT:
-jle, -jsonl-export string file to export results in JSONL(ine) format
-pe, -pdf-export string file to export results in PDF format
-rd, -redact string[] redact given list of keys from query parameter, request header and body
-nrd, -no-redact disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted
CONFIGURATIONS:
-config string path to the nuclei configuration file
+2 -3
View File
@@ -148,7 +148,7 @@ TARGET:
-iv, -ip-version string[] IP version to scan of hostname (4,6) - (default 4)
TARGET-FORMAT:
-im, -input-mode string mode of input file (list, burp, har, jsonl, yaml, openapi, swagger, http) (default "list")
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger, http) (default "list")
-ro, -required-only use only required fields in input format when generating requests
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
@@ -175,7 +175,7 @@ TEMPLATES:
-egm, -enable-global-matchers enable loading global matchers templates
-file enable loading file templates
-llm enable llm matchers and extractors (semantic matching)
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (default "openai")
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter) (default "openai")
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
-llm-model string model used for semantic matching
-llm-timeout int time in seconds to wait for a single llm call (default 30)
@@ -219,7 +219,6 @@ OUTPUT:
-jle, -jsonl-export string file to export results in JSONL(ine) format
-pe, -pdf-export string file to export results in PDF format
-rd, -redact string[] redact given list of keys from query parameter, request header and body
-nrd, -no-redact disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted
CONFIGURATIONS:
-config string path to the nuclei configuration file
+2 -3
View File
@@ -148,7 +148,7 @@ TARGET:
-iv, -ip-version string[] IP version to scan of hostname (4,6) - (default 4)
TARGET-FORMAT:
-im, -input-mode string mode of input file (list, burp, har, jsonl, yaml, openapi, swagger, http) (default "list")
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger, http) (default "list")
-ro, -required-only use only required fields in input format when generating requests
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
@@ -175,7 +175,7 @@ TEMPLATES:
-egm, -enable-global-matchers enable loading global matchers templates
-file enable loading file templates
-llm enable llm matchers and extractors (semantic matching)
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (default "openai")
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter) (default "openai")
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
-llm-model string model used for semantic matching
-llm-timeout int time in seconds to wait for a single llm call (default 30)
@@ -219,7 +219,6 @@ OUTPUT:
-jle, -jsonl-export string file to export results in JSONL(ine) format
-pe, -pdf-export string file to export results in PDF format
-rd, -redact string[] redact given list of keys from query parameter, request header and body
-nrd, -no-redact disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted
CONFIGURATIONS:
-config string path to the nuclei configuration file
+231 -96
View File
@@ -1,120 +1,255 @@
<!-- Generated by securitycontext.dev. Source HEAD: df57ae6d2955864fd92a1bd2565fe9e4e9a7592b. -->
<!-- Generated by securitycontext.dev. Source HEAD: 3e1e0e81f091c7817bbf7af64bb8eec6e8c2bac1. -->
# Security Context: projectdiscovery/nuclei @ dev
> Reference context for editing projectdiscovery/nuclei. Before changing any component below, **preserve the listed guards and do not reintroduce these fixed issues**. Each fingerprint is a regression check, and each shared attack surface is a guard that must keep holding on every path.
_Analyzed [df57ae6d29][c-df57ae6d29] on `dev` · 5174 commits, 548 security-relevant examined · generated 2026-11-02_
_Generated 2026-09-01 16:22 UTC by secc_
_Analyzed commit: 3e1e0e81f091c7817bbf7af64bb8eec6e8c2bac1_
**Remediation coverage: 56.7%**
_17 of 30 historical fixes have no remaining unguarded variant; 368 open remediation leads to verify. Measures fix consistency, not a vulnerability verdict._
- Commits analyzed: **4814**
- Commits flagged for review: **235**
- Confirmed historical security fixes: **32**
- Known published CVEs: **0**
## Summary
Nuclei's historical security posture is dominated by sandbox escapes and code execution via unsigned or unverified templates, with recurring SSRF, path traversal, and auth bypass issues across JS libraries, loaders, and workflow paths. The most critical theme is inconsistent enforcement of signature verification and local-file-access (-lfa) guards across direct, workflow, DAST, and JS-sandbox entry points, enabling variant attacks. A second theme is network policy (host allowlist) gaps in LDAP dialing and proxy handling. The security context also includes dependency upgrades (Alpine, go-git) and output file permission hardening, but the highest-value focus is on template trust boundaries and sandbox enforcement parity.
## Recurring Security Patterns
**7.1% of classified fixes repeat a previously fixed pattern** (1 recurring, 13 first-seen). A recurring pattern means this repository fixed a similar issue before — not that the code is currently vulnerable.
**15.6% of classified fixes repeat a previously fixed pattern** (5 recurring, 27 first-seen). A recurring pattern means this repository fixed a similar issue before — not that the code is currently vulnerable.
- **Code Execution:** [1c440e755a](https://github.com/projectdiscovery/nuclei/commit/1c440e755a97471c56fb0276ee8a8c4132974645) → [9de96e4dda](https://github.com/projectdiscovery/nuclei/commit/9de96e4dda5a03da963b9ae6582f03ea55791a76) — matched on `class:code execution`, `component:pkg/catalog/loader/loader.go`
- **Code Execution via Unsigned Template:** [fa56800fcc](https://github.com/projectdiscovery/nuclei/commit/fa56800fcca3c8c8fe8e8dec28584210ca1527de) → [fac0b7196d](https://github.com/projectdiscovery/nuclei/commit/fac0b7196d2fbca56c8af8ff8e1899c111041a37) — matched on `class:code execution via unsigned template`, `CWE-347`, `sink:loadtemplates`
- **SSRF:** [a167e6c57b](https://github.com/projectdiscovery/nuclei/commit/a167e6c57b80f5dc7be231231b62d7c594ee2770) → [9def9af7e3](https://github.com/projectdiscovery/nuclei/commit/9def9af7e3f7ebcf5cd561c37b623b9999ee7572) — matched on `class:ssrf`, `CWE-918`, `component:pkg/js/libs/ldap/ldap.go`
- **Dependency Vulnerability:** [d93b4a01df](https://github.com/projectdiscovery/nuclei/commit/d93b4a01df9c218a36dd2fccfb1c2c1f96b181fe) → [fcd5c6b111](https://github.com/projectdiscovery/nuclei/commit/fcd5c6b111ddcb746525f88b7d0bf4b4abbc2529) — matched on `class:dependency vulnerability`, `CWE-1104`, `component:dockerfile`
- **Sandbox Escape:** [96049a32fa](https://github.com/projectdiscovery/nuclei/commit/96049a32fa70aef0483d4180d386a57b3c2d973a) → [cf2a98b03d](https://github.com/projectdiscovery/nuclei/commit/cf2a98b03d76327c20fb0183b7f1c849eb692c91) — matched on `class:sandbox escape`, `CWE-284`, `component:v2/internal/runner/processor.go`, `sink:stdlib allmodulenames`, `sink:stdlib getmodulemap`
## Top Risks
Components with the strongest concentration of past fixes for one class.
### 1. Sandbox Escape: `pkg/js/compiler/pool.go, v2/pkg/protocols/javascript/js.go, v2/pkg/protocols/headless/engine/engine.go, v2/internal/runner/processor.go` (high)
| # | Class | Component | Severity | Fixes | Evidence |
|---|---|---|---|---|---|
| 1 | SSRF | `pkg/protocols/common/protocolstate/headless.go` | high | 2 | [d43c1f547f][c-d43c1f547f], [d9dabbb4a1][c-d9dabbb4a1] |
| 2 | Arbitrary File Access | `v2/pkg/protocols/common/generators/load.go` | high | 0 (1 component gone, not counted) | [e5154d362a][c-e5154d362a] |
| 3 | Arbitrary File Access | `pkg/js/libs/mysql/mysql.go` | high | 1 | [634cecbb28][c-634cecbb28] |
| 4 | Code Execution | `pkg/fuzz/execute.go` | high | 1 | [ccbfb12bd0][c-ccbfb12bd0] |
| 5 | Code Execution | `pkg/protocols/common/expressions/expressions.go` | high | 1 | [d221732016][c-d221732016] |
| 6 | Information Disclosure | `internal/httpapi/apiendpoint.go` | high | 1 | [a20aa26024][c-a20aa26024] |
Multiple sandbox escapes across JS require(), eval/ReadFile, headless browser flags, and tengo imports, indicating weak default sandbox boundaries that require consistent -lfa and safe-module enforcement.
## Fixed-Issue Fingerprints
Evidence: 6f2ade6a9b427c284c15a43445f9c7f055e60e5d, c35162c8ef061a96dd74b54c8f022c1a41cf5833, df55f7a2eb591a7949b5c66a9c54db54aa28f450, cf2a98b03d76327c20fb0183b7f1c849eb692c91, 96049a32fa70aef0483d4180d386a57b3c2d973a
Real security fixes mined from git history; use them as regression checks. `verified` fixes are linked to a published advisory, `inferred` fixes were confirmed from the diff evidence alone. Each is identified by its short commit SHA, which the variant-leads report uses to reference it back to this history.
### 2. Code Execution via Unsigned Template: `pkg/catalog/loader/loader.go, pkg/templates/workflows.go, pkg/templates/templates.go` (critical)
**By component** (directories with the most fixes):
Unsigned templates can execute code through direct loading, workflows, and DAST branches; signature verification was bypassed via cache poisoning and missing guards on specific paths.
- `pkg/protocols/common/protocolstate` — 2 fixes: SSRF (2)
Evidence: 9de96e4dda5a03da963b9ae6582f03ea55791a76, 1c440e755a97471c56fb0276ee8a8c4132974645, fac0b7196d2fbca56c8af8ff8e1899c111041a37, e86f38299765b82ad724fdb701557e0eaff3884d, fa56800fcca3c8c8fe8e8dec28584210ca1527de
- [1c0f77d28e][c-1c0f77d28e] **fix(js/net): enforce network policy on proxied target in JS net dial** — SSRF, high [CWE-918] · inferred · 2026-10-18
`pkg/js/libs/net/net.go` · sink `dialHTTPProxy` · fix: input validation
- [d43c1f547f][c-d43c1f547f] **fix(headless): canonicalize IPv4-mapped IPv6 literals and re-validate dial targets** — SSRF, high [CWE-918] · inferred · 2026-10-13
`pkg/protocols/common/protocolstate/headless.go` · sink `net.DialTimeout` · fix: canonicalization before validation
- [d9dabbb4a1][c-d9dabbb4a1] **fix(headless): enforce network policy on ws and wss schemes (#7867)** — SSRF, high [CWE-918] · inferred · 2026-10-12
`pkg/protocols/common/protocolstate/headless.go` · sink `isValidHost` · fix: canonicalization before validation
- [a20aa26024][c-a20aa26024] **Fix pprof/auth exposure on http-api-endpoint and harden file permissions** — Information Disclosure, high [CWE-200] · inferred · 2026-08-03
`internal/httpapi/apiendpoint.go` · sink `http.ListenAndServe` · fix: authentication verification
- [fac0b7196d][c-fac0b7196d] **feat(templates): require signatures for javascript templates (#7514)** — Signature Verification Bypass, high [CWE-347] · verified (CVE-2026-76819, GHSA-vxg7-f2jj-jmqm) · 2026-07-03
`pkg/catalog/loader/loader.go` · sink `t.HasJavascriptRequest` · fix: cryptographic verification
- [ccbfb12bd0][c-ccbfb12bd0] **refactor(protocols): template rendering boundary (#7499)** — Code Execution, high [CWE-94] · verified (CVE-2026-76805, GHSA-jpvm-9frm-hjcq) · 2026-06-30
`pkg/fuzz/execute.go` · sink `expressions.Evaluate` · fix: sandbox isolation
- [3a8b65cb45][c-3a8b65cb45] **fix(js): reject unsandboxed ccache writes in krbforge (#7482)** — Sandbox Escape, high [CWE-693] · inferred · 2026-06-24
`pkg/js/libs/krbforge/krbforge.go` · sink `OutputFile` · fix: sandbox isolation
- [68daef44a7][c-68daef44a7] **fix(js): escape dbname in lib/pq URL (#7479)** — SQL Injection, high [CWE-89] · inferred · 2026-06-23
`pkg/js/libs/postgres/postgres.go` · sink `fmt.Sprintf` (library call — the operation around it is the sink) · fix: context-aware output escaping
- [634cecbb28][c-634cecbb28] **gate mysql allowAllFiles behind -lfa** — Arbitrary File Access, high [CWE-22] · verified (CVE-2026-76803, GHSA-xhmx-w2j4-rw3q) · 2026-06-19
`pkg/js/libs/mysql/mysql.go` · sink `sql.Open` · fix: sandbox isolation
- [20cbf0bd73][c-20cbf0bd73] **refactor: use path-aware filesystem containment checks (#7420)** — Path Traversal, high [CWE-22] · inferred · 2026-06-04
`pkg/installer/template.go` · sink `filepath.Join` · fix: canonicalization before validation
- [6f2ade6a9b][c-6f2ade6a9b] **fix(js): respect \`allow-local-file-access\` in \`require\` (#7332)** — Sandbox Escape, high [CWE-693] · inferred · 2026-04-10
`pkg/js/compiler/pool.go` · sink `require.DefaultSourceLoader` · fix: sandbox isolation
- [d221732016][c-d221732016] **fix(expressions): only eval template-authored expressions (#7221)** — Code Execution, high [CWE-94] · verified (CVE-2026-76805, GHSA-jpvm-9frm-hjcq) · 2026-03-16
`pkg/protocols/common/expressions/expressions.go` · sink `FindExpressions` · fix: allowlist enforcement
- [0da993afe6][c-0da993afe6] **Merge commit from fork** — Signature Verification Bypass, medium [CWE-347] · verified (CVE-2024-43405, GHSA-7h5p-mmpp-hgmm) · 2024-08-19
`pkg/templates/signer/tmpl_signer.go` · sink `os.ReadFile` · fix: cryptographic verification
- [e5154d362a][c-e5154d362a] **fixing payload load (#3927)** — Arbitrary File Access, high [CWE-22] · verified (CVE-2023-37896, GHSA-2xx4-jj5v-6mff) · 2023-07-14
`v2/pkg/protocols/common/generators/load.go` · sink `loadPayloads` · fix: sandbox isolation
### 3. Auth Bypass: `pkg/templates/compile.go, pkg/templates/signer/tmpl_signer.go, pkg/catalog/loader/loader.go, pkg/protocols/http/request.go` (high)
Signature verification can be bypassed via cached metadata, improper signature extraction, or missing checks on specific load paths, and cookie jar bypass via disabled cookie handling.
Evidence: 4534e9cb3034ebe5cc7b0cecabe399b6fb9ce0e4, 0da993afe6d41b4b1b814e8fad23a2acba13c60a, 9bd4db3f74aedd067af568df4f9198a8a090f68b, a976ca8d3f6a18c9ca746c8ce73f28fdcd518642
### 4. SSRF: `pkg/js/libs/ldap/ldap.go, v2/internal/runner/proxy.go` (high)
SSRF via LDAP dialing to internal hosts and via proxy configuration; host allowlist checks were missing or applied to the wrong target (domain vs controller).
Evidence: 9def9af7e3f7ebcf5cd561c37b623b9999ee7572, a167e6c57b80f5dc7be231231b62d7c594ee2770, 0e46d3e0410f89e4af41099f5cd38fa7f54ae54f
### 5. Path Traversal: `pkg/catalog/config/nucleiconfig.go, v2/internal/runner/update.go, v2/pkg/protocols/common/protocolstate/headless.go` (high)
Path traversal via lexical prefix checks, zip-slip in template extraction, and file:// scheme in headless mode; canonicalization and containment checks are required.
Evidence: 20cbf0bd73893c1270582c9778b6b26a5cb11206, f9c214a66f37a00fc29fe64232b690e6285ec59c, d3928e080d819f25306bfbaecc399f03516db5aa
### 6. Code Execution: `pkg/catalog/loader/loader.go, pkg/templates/workflows.go` (high)
Code execution via cache poisoning and unverified code templates in workflows; requires -code flag and verified templates.
Evidence: 9de96e4dda5a03da963b9ae6582f03ea55791a76, e86f38299765b82ad724fdb701557e0eaff3884d
## Shared Attack Surfaces
Capabilities guarded by more than one past fix on different code paths. A guard enforced on one entry point is often missing on a sibling; verify each alternate path before assuming it's covered.
### 1. Template signature verification and unsigned-template opt-in enforcement across all load paths
- Guard that should hold everywhere: signature verification (template.Verified, Verify), DisableUnsignedTemplates flag, -code flag for code templates, cache invalidation on verification status/fingerprint/digest
- Entry points: `pkg/catalog/loader/loader.go (LoadTemplatesWithTags, DAST branch)`, `pkg/templates/workflows.go (loadTemplates, workflowExecutor)`, `pkg/templates/templates.go (loadTemplates)`, `pkg/templates/compile.go (template.Verified)`, `pkg/templates/signer/tmpl_signer.go (Verify)`
- Check: Inspect the DAST and workflow sub-loaders: they must enforce the same DisableUnsignedTemplates and -code checks as the direct loader, and must not rely on cached verification results without re-checking signatures.
- Evidence: 9de96e4dda5a03da963b9ae6582f03ea55791a76, 1c440e755a97471c56fb0276ee8a8c4132974645, fac0b7196d2fbca56c8af8ff8e1899c111041a37, e86f38299765b82ad724fdb701557e0eaff3884d, fa56800fcca3c8c8fe8e8dec28584210ca1527de, 4534e9cb3034ebe5cc7b0cecabe399b6fb9ce0e4, 0da993afe6d41b4b1b814e8fad23a2acba13c60a, 9bd4db3f74aedd067af568df4f9198a8a090f68b
### 2. Local file access (read/write) outside the sandbox, gated by -lfa flag
- Guard that should hold everywhere: -lfa flag enforcement, path canonicalization and containment (sandbox directory), file:// scheme blocking, allowAllFiles stripping, require() registry rebuild
- Entry points: `pkg/js/libs/krbforge/krbforge.go (CreateTicket)`, `pkg/js/libs/mysql/mysql_private.go (sql.Open)`, `pkg/js/compiler/pool.go (require)`, `v2/pkg/protocols/javascript/js.go (ReadFile, eval)`, `v2/pkg/protocols/common/protocolstate/headless.go (file:)`, `v2/pkg/protocols/common/generators/load.go (loadPayloadsFromFile)`
- Check: Verify that every JS library and protocol helper (krbforge, mysql, require, headless file://, payload loaders) enforces the -lfa flag and path containment; a sibling path that skips the -lfa check is a sandbox escape.
- Evidence: 3a8b65cb45f121fcdf418f60e5177ed9f250c122, 9611926a9a1ca99e8ed554d1b26fbeff60e06368, 6f2ade6a9b427c284c15a43445f9c7f055e60e5d, c35162c8ef061a96dd74b54c8f022c1a41cf5833, d3928e080d819f25306bfbaecc399f03516db5aa, e7fb40a41308f6c41267e858d1781eb67f7e0572
### 3. Network policy (host allowlist) enforcement before outbound connections
- Guard that should hold everywhere: IsHostAllowed check on the actual target host (controller, not domain), proxy validation and connectivity check, ProxyInternal gate for HTTP_PROXY env
- Entry points: `pkg/js/libs/ldap/ldap.go (Dial, newLdapSession)`, `v2/internal/runner/proxy.go (loadProxyServers, os.Setenv HTTP_PROXY)`
- Check: Inspect other dialing paths (e.g., HTTP, headless) to ensure they apply the same host allowlist check on the resolved IP/host, not just a user-supplied domain, and that proxy settings cannot redirect traffic to internal hosts.
- Evidence: 9def9af7e3f7ebcf5cd561c37b623b9999ee7572, a167e6c57b80f5dc7be231231b62d7c594ee2770, 0e46d3e0410f89e4af41099f5cd38fa7f54ae54f, 3f5c055aca1499642caf4f40e8cf55ad32bd4da9
### 4. Path traversal in file extraction and template path trust boundaries
- Guard that should hold everywhere: canonical path containment check (filepath.Rel, prefix), zip-slip validation against template directory, sandbox path prefix validation
- Entry points: `pkg/catalog/config/nucleiconfig.go (IsCustomTemplate)`, `v2/internal/runner/update.go (os.OpenFile)`, `v2/pkg/protocols/common/generators/load.go (loadPayloadsFromFile)`
- Check: Audit all file-write and file-read paths that accept template-controlled paths (e.g., template extraction, payload loading) to ensure they use canonical containment, not lexical prefix checks, and apply the same sandbox directory restriction.
- Evidence: 20cbf0bd73893c1270582c9778b6b26a5cb11206, f9c214a66f37a00fc29fe64232b690e6285ec59c, e7fb40a41308f6c41267e858d1781eb67f7e0572
## Historical Vulnerability Classes
| Class | Fixes | Verified | Most recent fix |
|---|---|---|---|
| SSRF | 3 | 0 | [2026-10-18][c-1c0f77d28e] 1c0f77d28e |
| Arbitrary File Access | 1 (1 component gone, not counted) | 2 | [2026-06-19][c-634cecbb28] 634cecbb28 |
| Code Execution | 2 | 2 | [2026-06-30][c-ccbfb12bd0] ccbfb12bd0 |
| Sandbox Escape | 2 | 0 | [2026-06-24][c-3a8b65cb45] 3a8b65cb45 |
| Signature Verification Bypass | 2 | 2 | [2026-07-03][c-fac0b7196d] fac0b7196d |
| Information Disclosure | 1 | 0 | [2026-08-03][c-a20aa26024] a20aa26024 |
| Path Traversal | 1 | 0 | [2026-06-04][c-20cbf0bd73] 20cbf0bd73 |
| SQL Injection | 1 | 0 | [2026-06-23][c-68daef44a7] 68daef44a7 |
| Class | Count |
|---|---|
| Sandbox Escape | 7 |
| Auth Bypass | 4 |
| Code Execution | 3 |
| Dependency Vulnerability | 3 |
| Path Traversal | 3 |
| SSRF | 3 |
| Code Execution via Unsigned Template | 2 |
| Arbitrary File Read | 1 |
| Cryptographic Weakness | 1 |
| Deserialization of Untrusted Data | 1 |
| Information Disclosure | 1 |
| Insecure File Permissions | 1 |
| Race Condition | 1 |
| Security Misconfiguration | 1 |
## Known Advisories
## Fixed-Issue Fingerprints
### Linked to a fix commit in this history (5)
These are real security fixes mined from git history. Use them as regression checks.
- **CVE-2026-76805** / GHSA-jpvm-9frm-hjcq (medium 5.3) CWE-200 — fixed in [ccbfb12bd0][c-ccbfb12bd0], [d221732016][c-d221732016]
- Environment Variable Disclosure via Response-Derived Data in DAST/Fuzz Mode. A vulnerability in Nuclei's DAST/fuzz expression evaluation path allows a malicious target server to trigger disclosure of ...
- **CVE-2026-76803** / GHSA-xhmx-w2j4-rw3q (medium 5.3) CWE-284 — fixed in [634cecbb28][c-634cecbb28]
- Local File Read via MySQL Client Sandbox Bypass. A vulnerability in Nuclei's JavaScript MySQL client library allows arbitrary local file reads that bypass the `-allow-local-file-access` (`-lfa`) sandb...
- **CVE-2026-76819** / GHSA-vxg7-f2jj-jmqm (high 8.6) CWE-94,CWE-787 — fixed in [fac0b7196d][c-fac0b7196d]
- Arbitrary Code Execution via Goja JavaScript Engine Vulnerability. A vulnerability in the Goja JavaScript engine used by Nuclei's `javascript:` protocol allows arbitrary native code execution on the s...
- **CVE-2024-43405** / GHSA-7h5p-mmpp-hgmm (high 7.4) CWE-78 — fixed in [0da993afe6][c-0da993afe6]
- Nuclei Template Signature Verification Bypass. ## Summary A vulnerability has been identified in Nuclei's template signature verification system that could allow an attacker to bypass the signature ch...
- **CVE-2023-37896** / GHSA-2xx4-jj5v-6mff (medium 5.9) CWE-22 — fixed in [e5154d362a][c-e5154d362a]
- Nuclei < 2.9.9 - Sandbox restriction bypass. ## Overview Affected: < 2.9.9 (patched 2.9.9).
### Without a linked fix commit (6)
Published elsewhere, fixed before the history window, or not yet linked; full descriptions are in the JSON context.
| Advisory | Severity | Published | CWE | Summary |
|---|---|---|---|---|
| CVE-2026-76804 / GHSA-qgw5-7j4f-fg97 | medium 5.5 | 2026-07-16 | CWE-284 | Local File Read via Workflow File-Protocol Gate Bypass. A vulnerability in Nuclei's workfl... |
| CVE-2026-76802 / GHSA-jpf4-98qj-qr67 | medium 4.7 | 2026-07-16 | CWE-78 | Arbitrary Command Execution via DAST Code Signature Bypass. A vulnerability in Nuclei's DA... |
| CVE-2026-41645 / GHSA-jm34-66cf-qpvr | medium 5.3 | 2026-04-18 | CWE-94 | Environment variable disclosure via Response-Derived DSL Expressions. A vulnerability in N... |
| CVE-2026-41646 / GHSA-29rg-wmcw-hpf4 | medium 5.5 | 2026-04-18 | CWE-284 | Local File Read via require() Module Loader Bypass. A vulnerability in Nuclei's JavaScript... |
| CVE-2024-40641 / GHSA-c3q9-c27p-cw9h | high 7.4 | 2024-07-17 | CWE-78 | Unsigned code template execution through workflows in projectdiscovery/nuclei. ### Summary... |
| CVE-2024-27920 / GHSA-w5wx-6g2r-r78q | high 7.4 | 2024-03-15 | CWE-78 | Unsigned code template execution through workflows in projectdiscovery/nuclei. ### Overvie... |
[c-df57ae6d29]: https://github.com/projectdiscovery/nuclei/commit/df57ae6d2955864fd92a1bd2565fe9e4e9a7592b
[c-d43c1f547f]: https://github.com/projectdiscovery/nuclei/commit/d43c1f547f98ac56654f4335ad20af0cca4c229c
[c-d9dabbb4a1]: https://github.com/projectdiscovery/nuclei/commit/d9dabbb4a19e897a6bc12334a030a43035b27b73
[c-e5154d362a]: https://github.com/projectdiscovery/nuclei/commit/e5154d362af900e83b30fb79028bfab1a7f0c648
[c-634cecbb28]: https://github.com/projectdiscovery/nuclei/commit/634cecbb289db494247c04a9c4ec6a113aef3cc0
[c-ccbfb12bd0]: https://github.com/projectdiscovery/nuclei/commit/ccbfb12bd01447ba25f6e755dfb2bee677736da6
[c-d221732016]: https://github.com/projectdiscovery/nuclei/commit/d2217320162d5782ca7cb95bef9dda17063818f3
[c-a20aa26024]: https://github.com/projectdiscovery/nuclei/commit/a20aa260245e5db889c5a421d4a65aeb2a64bf46
[c-1c0f77d28e]: https://github.com/projectdiscovery/nuclei/commit/1c0f77d28ed639a34c2036fa23c7503ffac0bf27
[c-fac0b7196d]: https://github.com/projectdiscovery/nuclei/commit/fac0b7196d2fbca56c8af8ff8e1899c111041a37
[c-3a8b65cb45]: https://github.com/projectdiscovery/nuclei/commit/3a8b65cb45f121fcdf418f60e5177ed9f250c122
[c-68daef44a7]: https://github.com/projectdiscovery/nuclei/commit/68daef44a7eafd41c39690924620e418762909ef
[c-20cbf0bd73]: https://github.com/projectdiscovery/nuclei/commit/20cbf0bd73893c1270582c9778b6b26a5cb11206
[c-6f2ade6a9b]: https://github.com/projectdiscovery/nuclei/commit/6f2ade6a9b427c284c15a43445f9c7f055e60e5d
[c-0da993afe6]: https://github.com/projectdiscovery/nuclei/commit/0da993afe6d41b4b1b814e8fad23a2acba13c60a
- **Auth Bypass** (high) [CWE-287]: Honors disable-cookie by preventing the shared cookie jar from being applied when cookie handling is disabled, closing a path where cookies could be reused across requests.
- sink: `SetCookieJar(input.CookieJar)`
- fix: Adds !request.DisableCookie check before applying shared cookie jar
- commit: https://github.com/projectdiscovery/nuclei/commit/a976ca8d3f6a18c9ca746c8ce73f28fdcd518642 (2026-08-25)
- **Code Execution** (high) [CWE-347]: This commit fixes a cache poisoning vulnerability where unsigned templates could bypass signature verification and execute malicious code during compilation or request execution.
- sink: `template compilation and execution of JavaScript`
- fix: cache invalidation based on verification status, verifier fingerprint, and content digest
- commit: https://github.com/projectdiscovery/nuclei/commit/9de96e4dda5a03da963b9ae6582f03ea55791a76 (2026-08-04)
- **Code Execution via Unsigned Template** (high) [CWE-347]: Enforces signature verification on JavaScript templates, preventing execution of unsigned templates that could lead to arbitrary code execution.
- sink: `loading unsigned javascript templates into execution paths`
- fix: Require signature verification for javascript templates in direct loading and workflows when signing is enforced
- commit: https://github.com/projectdiscovery/nuclei/commit/fac0b7196d2fbca56c8af8ff8e1899c111041a37 (2026-07-03)
- **Sandbox Escape** (high) [CWE-22]: Enforces nuclei's local file access policy on krbforge output paths, preventing arbitrary file writes outside the sandbox.
- sink: `gpkrb.CreateTicket(cfg) with cfg.OutputFile`
- fix: Path normalization into sandbox and denial of outside paths unless -lfa
- commit: https://github.com/projectdiscovery/nuclei/commit/3a8b65cb45f121fcdf418f60e5177ed9f250c122 (2026-06-24)
- **SSRF** (high) [CWE-918]: Adds network policy host allowlist checks before LDAP dialing, preventing SSRF to restricted internal hosts.
- sink: `dialers.Fastdialer.Dial`
- fix: host allowlist check via IsHostAllowed before dialing
- commit: https://github.com/projectdiscovery/nuclei/commit/9def9af7e3f7ebcf5cd561c37b623b9999ee7572 (2026-06-24)
- **Arbitrary File Read** (high) [CWE-22]: Gates MySQL's allowAllFiles option behind the -lfa flag, preventing arbitrary local file reads via LOAD DATA LOCAL INFILE.
- sink: `sql.Open("mysql", dsn) with AllowAllFiles=true`
- fix: DSN sandbox that strips allowAllFiles unless -lfa is set
- commit: https://github.com/projectdiscovery/nuclei/commit/9611926a9a1ca99e8ed554d1b26fbeff60e06368 (2026-06-19)
- **Code Execution** (critical) [CWE-94]: Moves the unsigned code-template guard ahead of the DAST branch, preventing unsigned code templates from being loaded and executed under DAST, which previously bypassed the signature check.
- sink: `Template loading and execution`
- fix: Hoisting unsigned code-template guard before DAST branch to ensure it applies on all load paths
- commit: https://github.com/projectdiscovery/nuclei/commit/1c440e755a97471c56fb0276ee8a8c4132974645 (2026-06-19)
- **Path Traversal** (high) [CWE-22]: This commit replaces lexical prefix checks with canonical path containment checks across multiple filesystem trust boundaries, preventing path traversal where sibling paths were incorrectly treated as trusted.
- sink: `IsCustomTemplate`
- fix: path canonicalization and containment check
- commit: https://github.com/projectdiscovery/nuclei/commit/20cbf0bd73893c1270582c9778b6b26a5cb11206 (2026-06-04)
- **Information Disclosure** (medium) [CWE-200]: This commit fixes a data leakage issue in workflows where sibling state could leak across concurrent executions, and race requests ignored dynamic values, by syncing context and cloning inputs.
- sink: `syncWorkflowInputToTemplateCtx`
- fix: context synchronization and input cloning
- commit: https://github.com/projectdiscovery/nuclei/commit/805bcfe76f3629dbe71021795d4ce865406c1555 (2026-05-08)
- **Sandbox Escape** (high) [CWE-284]: Fixes sandbox escape where require() bypassed local file access restrictions and reused privileged module cache across executions.
- sink: `require() using default host filesystem loader`
- fix: Rebuild require registry per execution and enforce allow-local-file-access sandbox rules
- commit: https://github.com/projectdiscovery/nuclei/commit/6f2ade6a9b427c284c15a43445f9c7f055e60e5d (2026-04-10)
- **Cryptographic Weakness** (medium) [CWE-338]: Replaces predictable math/rand PRNG with cryptographically secure crypto/rand in JS helper functions.
- sink: `rand.Intn(255) / rand.Int63()`
- fix: Replace predictable math/rand with crypto/rand
- commit: https://github.com/projectdiscovery/nuclei/commit/44ef9ce34ffcfdfca3a96626abc73d99dd0e83ab (2026-03-15)
- **Auth Bypass** (high) [CWE-347]: Caching signature verification results without cryptographic re-check allows a metadata index compromise to bypass template signature verification.
- sink: `template.Verified, template.TemplateVerifier`
- fix: cached verification data reuse
- commit: https://github.com/projectdiscovery/nuclei/commit/4534e9cb3034ebe5cc7b0cecabe399b6fb9ce0e4 (2026-01-21)
- **Race Condition** (medium) [CWE-362]: Fixes a race condition in workflow execution caused by shared context callbacks, which could lead to data corruption or crashes; the primary impact is reliability, not a direct security breach.
- sink: `shared context callbacks in workflow execution`
- fix: fix race condition by making workflow execution sequential
- commit: https://github.com/projectdiscovery/nuclei/commit/24d1f58682027e23cb21a299a170abd2bdf20dc2 (2025-12-05)
- **Auth Bypass** (high) [CWE-347]: Fixes template signature verification by centralizing signature extraction and requiring the signature to be at the end of the template, preventing bypass.
- sink: `Verify`
- fix: signature extraction and verification
- commit: https://github.com/projectdiscovery/nuclei/commit/0da993afe6d41b4b1b814e8fad23a2acba13c60a (2024-08-19)
- **Dependency Vulnerability** (medium) [CWE-1104]: Upgrades Alpine base image from 3.18.5 to 3.18.6 to fix known security vulnerabilities in bundled packages.
- sink: `FROM alpine:3.18.5`
- fix: Upgrade base image to patched version
- commit: https://github.com/projectdiscovery/nuclei/commit/fcd5c6b111ddcb746525f88b7d0bf4b4abbc2529 (2024-03-15)
- **Dependency Vulnerability** (medium) [CWE-1104]: Upgrades Alpine base image from 3.18.2 to 3.18.5 to fix known security vulnerabilities in bundled packages.
- sink: `FROM alpine:3.18.2`
- fix: Upgrade base image to patched version
- commit: https://github.com/projectdiscovery/nuclei/commit/d93b4a01df9c218a36dd2fccfb1c2c1f96b181fe (2024-03-15)
- **Dependency Vulnerability** (high) [CWE-1035]: Updates go-git to fix known vulnerabilities (CVE-2023-49569, CVE-2023-49568), which could be exploited when processing untrusted git data.
- sink: `go-git library usage`
- fix: dependency upgrade to patched version
- commit: https://github.com/projectdiscovery/nuclei/commit/1700cdf4e23128a358066364062246b58c1c013f (2024-03-14)
- **Code Execution via Unsigned Template** (high) [CWE-347]: Introduces a flag to disable unsigned templates, preventing execution of unverified templates that could lead to arbitrary code execution.
- sink: `loading unsigned templates when -dut flag is set`
- fix: Adds -disable-unsigned-templates flag to skip unsigned templates and report them separately
- commit: https://github.com/projectdiscovery/nuclei/commit/fa56800fcca3c8c8fe8e8dec28584210ca1527de (2024-03-14)
- **Auth Bypass** (medium) [CWE-347]: This commit introduces a flag to skip unsigned templates, closing a path where an attacker could execute untrusted templates by bypassing signature verification.
- sink: `parsed.Verified`
- fix: skip unverified templates when DisableUnsignedTemplates is set
- commit: https://github.com/projectdiscovery/nuclei/commit/9bd4db3f74aedd067af568df4f9198a8a090f68b (2024-03-10)
- **Code Execution** (high) [CWE-94]: Prevents execution of unverified code templates in workflows, mitigating code execution risk.
- sink: `template.RequestsCode`
- fix: require -code flag and verified templates for code templates in workflows
- commit: https://github.com/projectdiscovery/nuclei/commit/e86f38299765b82ad724fdb701557e0eaff3884d (2024-03-01)
- **SSRF** (high) [CWE-918]: Fixes SSRF by validating the actual LDAP controller host against the network policy instead of the domain name.
- sink: `newLdapSession`
- fix: host allowlist check on controller instead of domain
- commit: https://github.com/projectdiscovery/nuclei/commit/a167e6c57b80f5dc7be231231b62d7c594ee2770 (2024-01-20)
- **Sandbox Escape** (high) [CWE-94]: Disables eval and tightens file read validation in the JavaScript sandbox, preventing template-driven code execution and file access outside the sandbox.
- sink: `eval function and file read in sandbox mode`
- fix: Disable eval function and rewrite file validation in sandbox mode
- commit: https://github.com/projectdiscovery/nuclei/commit/c35162c8ef061a96dd74b54c8f022c1a41cf5833 (2023-10-13)
- **Path Traversal** (high) [CWE-22]: Adds network policy and blocks file:// protocol in headless mode unless -lfa is set, preventing local file disclosure.
- sink: `file:// protocol access in headless browser`
- fix: Network policy validation and file:// scheme blocking when local file access is disabled
- commit: https://github.com/projectdiscovery/nuclei/commit/d3928e080d819f25306bfbaecc399f03516db5aa (2023-08-25)
- **Deserialization of Untrusted Data** (medium) [CWE-502]: Fixes JSON deserialization by adding explicit UnmarshalJSON methods and correcting tags, preventing type confusion and potential logic bypasses.
- sink: `UnmarshalJSON on user-controlled fields like Type and Name`
- fix: Adds proper UnmarshalJSON implementations and corrects json tags to prevent type confusion
- commit: https://github.com/projectdiscovery/nuclei/commit/85090b75317977bb9301444c9dfd8e4c2dba667a (2023-02-07)
- **Sandbox Escape** (medium) [CWE-22]: The commit adds sandbox mode that restricts payload file paths to the template directory, preventing arbitrary file reads when sandbox is enabled.
- sink: `loadPayloadsFromFile`
- fix: path prefix validation against template directory and template path directory
- commit: https://github.com/projectdiscovery/nuclei/commit/e7fb40a41308f6c41267e858d1781eb67f7e0572 (2022-11-16)
- **Insecure File Permissions** (low) [CWE-732]: Fixes overly permissive file permissions on debug output files by changing from os.ModePerm (0777) to 0644.
- sink: `os.OpenFile`
- fix: restrict file mode to 0644
- commit: https://github.com/projectdiscovery/nuclei/commit/af4854f90de728bb08689d5cae3dec4acfdb6b9a (2022-06-20)
- **Security Misconfiguration** (medium) [CWE-16]: Prevents setting HTTP_PROXY environment variable unless explicitly enabled, avoiding potential proxy hijacking.
- sink: `os.Setenv(types.HTTP_PROXY_ENV, proxyURL.String())`
- fix: Added check for options.ProxyInternal before setting HTTP_PROXY environment variable
- commit: https://github.com/projectdiscovery/nuclei/commit/3f5c055aca1499642caf4f40e8cf55ad32bd4da9 (2022-03-09)
- **Sandbox Escape** (high) [CWE-250]: The commit restricts the use of the --no-sandbox flag to Linux root users, preventing sandbox escape vulnerabilities in other environments.
- sink: `NoSandbox(true)`
- fix: Conditionally disable sandbox only on Linux as root
- commit: https://github.com/projectdiscovery/nuclei/commit/df55f7a2eb591a7949b5c66a9c54db54aa28f450 (2021-12-05)
- **Path Traversal** (high) [CWE-22]: This commit fixes a zip-slip path traversal vulnerability by validating the extracted file path against the configured template directory.
- sink: `os.OpenFile`
- fix: path canonicalization and prefix check
- commit: https://github.com/projectdiscovery/nuclei/commit/f9c214a66f37a00fc29fe64232b690e6285ec59c (2021-11-25)
- **SSRF** (high) [CWE-918]: Adds validation and connectivity checks for SOCKS5 proxies before launching a scan, preventing SSRF through proxy configuration.
- sink: `Proxy connection validation and loading`
- fix: Proxy server validation and connectivity check before scan launch
- commit: https://github.com/projectdiscovery/nuclei/commit/0e46d3e0410f89e4af41099f5cd38fa7f54ae54f (2021-11-10)
- **Sandbox Escape** (high) [CWE-284]: This commit adds a sandbox flag that, when enabled, restricts the tengo scripting engine to safe modules, preventing sandbox escape and arbitrary command execution.
- sink: `stdlib.GetModuleMap(stdlib.AllModuleNames()...)`
- fix: Added sandbox flag to restrict imports to safe modules when sandbox is enabled
- commit: https://github.com/projectdiscovery/nuclei/commit/cf2a98b03d76327c20fb0183b7f1c849eb692c91 (2020-12-13)
- **Sandbox Escape** (high) [CWE-284]: This commit restricts the tengo scripting engine to a safe module list, preventing sandbox escape and arbitrary command execution via workflow templates.
- sink: `stdlib.GetModuleMap(stdlib.AllModuleNames()...)`
- fix: Restricted tengo script imports to a safe module list
- commit: https://github.com/projectdiscovery/nuclei/commit/96049a32fa70aef0483d4180d386a57b3c2d973a (2020-12-13)
---
_Fixed-issue fingerprints and advisory matches are derived from git history and published advisories; the risk tables and pattern descriptions above are derived from them and should be verified against the cited commits._
_Fixed-issue fingerprints and CVE matches are derived from real data; LLM-written prose is advisory and should be verified against the cited commits._
-2
View File
@@ -3775,8 +3775,6 @@ Part Definitions:
- <code>req</code> - Headless request made from the client
- <code>duration</code> - Latest measured operation duration in seconds
- <code>resp,body,data</code> - Headless response received from client (default)
- <code>header,all_headers</code> - Headless response headers
- <code>status_code</code> - Status code received from the server
<hr />
+2 -2
View File
@@ -20,11 +20,11 @@ extend-exclude = [
"vendor/",
# Go checksum file
"go.sum",
# Generated history. Commit SHAs are not words.
"SECURITY_CONTEXT.md",
]
[default.extend-identifiers]
# Proper noun (OpenVAS scanner, source of the QoD confidence model)
OpenVAS = "OpenVAS"
# Go identifiers that cannot be renamed without breaking API
MisMatched = "MisMatched"
IsMisMatchedCert = "IsMisMatchedCert" # tlsx clients API
+4 -5
View File
@@ -271,10 +271,10 @@ on extensive configurability, massive extensibility and ease of use.`)
flagSet.StringSliceVarP(&options.Targets, "target", "u", nil, "target URLs/hosts to scan", goflags.CommaSeparatedStringSliceOptions),
flagSet.StringVarP(&options.TargetsFilePath, "list", "l", "", "path to file containing a list of target URLs/hosts to scan (one per line)"),
flagSet.StringVarP(&options.InlineTargetsList, "targets-inline", "", "", "inline multiline target list (for use in template profiles)"),
flagSet.StringSliceVarP(&options.ExcludeTargets, "exclude-hosts", "eh", nil, "hosts to exclude to scan from the input list (ip, cidr, hostname)", goflags.FileNormalizedOriginalStringSliceOptions),
flagSet.StringSliceVarP(&options.ExcludeTargets, "exclude-hosts", "eh", nil, "hosts to exclude to scan from the input list (ip, cidr, hostname)", goflags.FileCommaSeparatedStringSliceOptions),
flagSet.StringVar(&options.Resume, "resume", "", "resume scan from and save to specified file (clustering will be disabled)"),
flagSet.BoolVarP(&options.ScanAllIPs, "scan-all-ips", "sa", false, "scan all the IP's associated with dns record"),
flagSet.StringSliceVarP(&options.IPVersion, "ip-version", "iv", nil, "IP version to scan of hostname (4,6); default uses dialer fallback, -sa scans all addresses", goflags.CommaSeparatedStringSliceOptions),
flagSet.StringSliceVarP(&options.IPVersion, "ip-version", "iv", nil, "IP version to scan of hostname (4,6) - (default 4)", goflags.CommaSeparatedStringSliceOptions),
flagSet.BoolVarP(&options.DisableURLNormalization, "disable-url-normalization", "dun", false, "keep targets exactly as supplied instead of collapsing urls that address the same resource"),
flagSet.IntVarP(&options.MaxURLsPerPattern, "max-urls-per-pattern", "mup", 0, "scan at most this many urls sharing one structural shape, e.g. /user/1 and /user/2 (0 to scan all)"),
)
@@ -309,7 +309,7 @@ on extensive configurability, massive extensibility and ease of use.`)
flagSet.BoolVarP(&options.EnableGlobalMatchersTemplates, "enable-global-matchers", "egm", false, "enable loading global matchers templates"),
flagSet.BoolVar(&options.EnableFileTemplates, "file", false, "enable loading file templates"),
flagSet.BoolVar(&options.EnableLLM, "llm", false, "enable llm matchers and extractors (semantic matching)"),
flagSet.StringVar(&options.LLMProvider, "llm-provider", "openai", "llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev)"),
flagSet.StringVar(&options.LLMProvider, "llm-provider", "openai", "llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter)"),
flagSet.StringVar(&options.LLMBaseURL, "llm-base-url", "", "openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)"),
flagSet.StringVar(&options.LLMModel, "llm-model", "", "model used for semantic matching"),
flagSet.IntVar(&options.LLMTimeout, "llm-timeout", 30, "time in seconds to wait for a single llm call"),
@@ -355,7 +355,6 @@ on extensive configurability, massive extensibility and ease of use.`)
flagSet.StringVarP(&options.JSONLExport, "jsonl-export", "jle", "", "file to export results in JSONL(ine) format"),
flagSet.StringVarP(&options.PDFExport, "pdf-export", "pe", "", "file to export results in PDF format"),
flagSet.StringSliceVarP(&options.Redact, "redact", "rd", nil, "redact given list of keys from query parameter, request header and body", goflags.CommaSeparatedStringSliceOptions),
flagSet.BoolVarP(&options.NoRedact, "no-redact", "nrd", false, "disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted"),
)
flagSet.CreateGroup("configs", "Configurations",
@@ -470,8 +469,8 @@ on extensive configurability, massive extensibility and ease of use.`)
flagSet.BoolVarP(&options.DisableHTTPProbe, "no-httpx", "nh", false, "disable httpx probing for non-url input"),
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.BoolVarP(&options.ConfidenceBaseline, "confidence-baseline", "cob", false, "send a per-host control request to lower confidence on catch-all (false-positive) matches"),
flagSet.BoolVar(&options.EnableHTTPCache, "http-cache", false, "enable HTTP cache (RFC 9111) for HTTP requests"),
flagSet.BoolVarP(&options.AutoCalibrate, "auto-calibrate", "ac", false, "drop HTTP matches on a host's catch-all page (answers missing paths with 2xx/3xx)"),
)
flagSet.CreateGroup("headless", "Headless",
+12 -12
View File
@@ -18,11 +18,10 @@ require (
github.com/alecthomas/chroma v0.10.0
github.com/alexsnet/go-vnc v0.1.0
github.com/alitto/pond v1.9.2
github.com/andybalholm/brotli v1.2.0
github.com/andygrunwald/go-jira v1.16.1
github.com/antchfx/htmlquery v1.3.7
github.com/antchfx/htmlquery v1.3.5
github.com/antchfx/xmlquery v1.4.4
github.com/antchfx/xpath v1.3.9
github.com/antchfx/xpath v1.3.6
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.31.16
github.com/aws/aws-sdk-go-v2/credentials v1.18.20
@@ -34,11 +33,10 @@ require (
github.com/clbanning/mxj/v2 v2.7.0
github.com/ditashi/jsbeautifier-go v0.0.0-20141206144643-2520a8026a9c
github.com/docker/go-units v0.5.0
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707
github.com/elazarl/goproxy v1.7.2
github.com/fatih/structs v1.1.0
github.com/fullstorydev/grpcurl v1.9.3
github.com/getkin/kin-openapi v0.149.0
github.com/getkin/kin-openapi v0.144.0
github.com/getsops/sops/v3 v3.9.2
github.com/go-echarts/go-echarts/v2 v2.6.0
github.com/go-git/go-git/v5 v5.19.2
@@ -63,7 +61,6 @@ 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/klauspost/compress v1.18.7
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
@@ -78,7 +75,7 @@ require (
github.com/pkg/errors v0.9.1
github.com/praetorian-inc/fingerprintx v1.1.15
github.com/projectdiscovery/clistats v0.1.7
github.com/projectdiscovery/dsl v0.9.0
github.com/projectdiscovery/dsl v0.8.24
github.com/projectdiscovery/fastdialer v0.6.0
github.com/projectdiscovery/fasttemplate v0.0.2
github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c
@@ -88,7 +85,7 @@ require (
github.com/projectdiscovery/goja_nodejs v0.0.0-20260618132410-8519f75f703d
github.com/projectdiscovery/gologger v1.1.73
github.com/projectdiscovery/gostruct v0.0.2
github.com/projectdiscovery/govaluate v0.0.0-20261102102118-9294133b15a2
github.com/projectdiscovery/govaluate v0.0.0-20260615100919-5ee2581bbf7e
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76
github.com/projectdiscovery/hmap v0.0.102
github.com/projectdiscovery/httpx v1.12.0
@@ -105,8 +102,8 @@ require (
github.com/projectdiscovery/tlsx v1.4.3-0.20261020052910-acb80c2c7c75
github.com/projectdiscovery/uncover v1.2.1
github.com/projectdiscovery/useragent v0.0.109
github.com/projectdiscovery/utils v0.11.13-0.20261103094816-5c0b20e35350
github.com/projectdiscovery/wappalyzergo v0.3.6
github.com/projectdiscovery/utils v0.11.12-0.20261025172519-64b3c221cfe5
github.com/projectdiscovery/wappalyzergo v0.3.5
github.com/projectdiscovery/yamldoc-go v1.0.7
github.com/redis/go-redis/v9 v9.19.0
github.com/remeh/sizedwaitgroup v1.0.0
@@ -116,7 +113,6 @@ require (
github.com/segmentio/ksuid v1.0.4
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466
github.com/sijms/go-ora/v2 v2.9.0
github.com/spaolacci/murmur3 v1.1.0
github.com/spf13/cast v1.10.0
github.com/stretchr/testify v1.12.1
github.com/syndtr/goleveldb v1.0.0
@@ -179,6 +175,7 @@ require (
github.com/alecthomas/kingpin/v2 v2.4.0 // indirect
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/cascadia v1.3.4 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
@@ -242,6 +239,7 @@ require (
github.com/docker/cli v29.2.0+incompatible // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
@@ -325,6 +323,7 @@ require (
github.com/kaiakz/ubuffer v0.0.0-20200803053910-dd1083087166 // indirect
github.com/kataras/jwt v0.1.10 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/compress v1.18.7 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
@@ -386,13 +385,14 @@ require (
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/shirou/gopsutil v3.21.11+incompatible // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/sirupsen/logrus v1.10.2 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spiffe/go-spiffe/v2 v2.7.0 // indirect
+17 -16
View File
@@ -180,13 +180,14 @@ github.com/andygrunwald/go-jira v1.16.1 h1:WoQEar5XoDRAibOgKzTFELlPNlKAtnfWr296R
github.com/andygrunwald/go-jira v1.16.1/go.mod h1:UQH4IBVxIYWbgagc0LF/k9FRs9xjIiQ8hIcC6HfLwFU=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/antchfx/htmlquery v1.3.7 h1:KCUklE1EO/xm6iS3CWx6iatFlcnENBk4I4UYa2pBmLU=
github.com/antchfx/htmlquery v1.3.7/go.mod h1:/uBTMnFEl6LaBlIgw3KZ+c061tAQdlfbCXObZoxwQf4=
github.com/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0=
github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA=
github.com/antchfx/xmlquery v1.4.4 h1:mxMEkdYP3pjKSftxss4nUHfjBhnMk4imGoR96FRY2dg=
github.com/antchfx/xmlquery v1.4.4/go.mod h1:AEPEEPYE9GnA2mj5Ur2L5Q5/2PycJ0N9Fusrx9b12fc=
github.com/antchfx/xpath v1.3.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antchfx/xpath v1.3.9 h1:muXJSod6jRzhsiUjvduNSxsRSptFZ2GOJ1oMD1QkPSA=
github.com/antchfx/xpath v1.3.9/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
@@ -426,8 +427,8 @@ github.com/gaissmai/bart v0.30.0 h1:K1oTSQ6Nf76bhrgxeTqzZa4hE0M9x4xDfL6n6UG67Sc=
github.com/gaissmai/bart v0.30.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64=
github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
github.com/getkin/kin-openapi v0.149.0 h1:ZbhmVJ4yq5RZDUsyP8lcBcGMsjsaTqXEFt6isdtMDfA=
github.com/getkin/kin-openapi v0.149.0/go.mod h1:1+BHDzstro+P5CKtPy1X4PfofnFgmRe6uvMy9+r9fKY=
github.com/getkin/kin-openapi v0.144.0 h1:hIRcTH+KjLfkLpYU6bSSfdFpi0fZi1fp+hSPi4aQu9Y=
github.com/getkin/kin-openapi v0.144.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
github.com/getsops/gopgagent v0.0.0-20240527072608-0c14999532fe h1:QKe/kmAYbndxwu91TcjHERsnMh5SgOB1x/qicvOdUJ8=
github.com/getsops/gopgagent v0.0.0-20240527072608-0c14999532fe/go.mod h1:awFzISqLJoZLm+i9QQ4SgMNHDqljH6jWV0B36V5MrUM=
github.com/getsops/sops/v3 v3.9.2 h1:Cuuahc/UGu7W3+NXd+mrl+2oXGxxj/jIPsw6Ah2CbxA=
@@ -934,8 +935,8 @@ github.com/projectdiscovery/cdncheck v1.3.2 h1:I453ZWNBeG0WWIy3wi9412l4PTzorLgeb
github.com/projectdiscovery/cdncheck v1.3.2/go.mod h1:5IkUMMlcGaeg1DWW5oJVd3r9X3EpJ88j5HxBeF4Me9M=
github.com/projectdiscovery/clistats v0.1.7 h1:NXf4lUzx1QcbJd6QycNwwYHTv2FsW9MchQ2LsgSL6C0=
github.com/projectdiscovery/clistats v0.1.7/go.mod h1:RxRGxhMzkT344VTTvZWIrojurUXdQQRN1yfwgg/88I8=
github.com/projectdiscovery/dsl v0.9.0 h1:i1KyCbKJyKfHkgdbfh0JkWdd14APUjpEYf+GaYANlCg=
github.com/projectdiscovery/dsl v0.9.0/go.mod h1:ntNFkkiYCTEosGFTbApA61RMrvJTCjnakPxnWj56MyY=
github.com/projectdiscovery/dsl v0.8.24 h1:rN2w6LSIjA16qKGRjyj70oxdwXGaspe6sAjEkP7F2fk=
github.com/projectdiscovery/dsl v0.8.24/go.mod h1:V+EludvS5PS19UNMlM3CuBwZ+sFnMaQhCFGxysDUanQ=
github.com/projectdiscovery/fastdialer v0.6.0 h1:4ZeFQ6LyRH4D8e3qO/yzqIP4Cf9Fr0DduaVpJD6ENnE=
github.com/projectdiscovery/fastdialer v0.6.0/go.mod h1:FpHwa9S5PUgfPbh+irOm+cdU6AO631XidxkhPwPv7yY=
github.com/projectdiscovery/fasttemplate v0.0.2 h1:h2cISk5xDhlJEinlBQS6RRx0vOlOirB2y3Yu4PJzpiA=
@@ -956,8 +957,8 @@ github.com/projectdiscovery/gologger v1.1.73 h1:WX2nzq29cL7HdvOHqIttacoid/5lMwap
github.com/projectdiscovery/gologger v1.1.73/go.mod h1:mJwODZcFDg70ihINpOvZevmBtgvpP8H9/l8Y+OPhZPY=
github.com/projectdiscovery/gostruct v0.0.2 h1:s8gP8ApugGM4go1pA+sVlPDXaWqNP5BBDDSv7VEdG1M=
github.com/projectdiscovery/gostruct v0.0.2/go.mod h1:H86peL4HKwMXcQQtEa6lmC8FuD9XFt6gkNR0B/Mu5PE=
github.com/projectdiscovery/govaluate v0.0.0-20261102102118-9294133b15a2 h1:aSLbDvrJQPlp4HvojU+bTU5XV8xdAVCjoc5gX1Wfbw4=
github.com/projectdiscovery/govaluate v0.0.0-20261102102118-9294133b15a2/go.mod h1:Z3VjpUJV1pnXDwvAddA8aZ4e839SoeftB7CSdZntAVI=
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/hmap v0.0.102 h1:ybPuf7UQvuOLiHLOUbVMvd+PSlItQU5iQpP1Erl8kaU=
@@ -998,10 +999,10 @@ github.com/projectdiscovery/uncover v1.2.1 h1:8U46T/96CLT7BPoXBgkTvWqB06lOyeTSLv
github.com/projectdiscovery/uncover v1.2.1/go.mod h1:0p8onrWxfpXQEYs90ZDzTSpu1107gWmodX1NWqu/+z4=
github.com/projectdiscovery/useragent v0.0.109 h1:b86BPdZvmgbJousa04jAugDRY4Y+rAvEamE0TFCPmeE=
github.com/projectdiscovery/useragent v0.0.109/go.mod h1:sRyBqDAcD31RKYrO8fyXGowLd36fNwY1g0MNW9mp7zk=
github.com/projectdiscovery/utils v0.11.13-0.20261103094816-5c0b20e35350 h1:uXqpYa6XKV9dCvoti9PbSgIWIsBKPu5YKMywB9G4q0Q=
github.com/projectdiscovery/utils v0.11.13-0.20261103094816-5c0b20e35350/go.mod h1:TJgsyVjB02CavwZVFzUzPg6YOSJXQxLhL808JCiQKlU=
github.com/projectdiscovery/wappalyzergo v0.3.6 h1:1FXCMeCa1xVu2zaW+eeBcbVxtlMStqGOlKcMLyh2pzI=
github.com/projectdiscovery/wappalyzergo v0.3.6/go.mod h1:4E6+fh/ckaY3wo5tlM7lNYSkhuTYRHzu2YmGIRraXP0=
github.com/projectdiscovery/utils v0.11.12-0.20261025172519-64b3c221cfe5 h1:AOspf7ScbwZr8A2YnjCFd4ZJAMSFqzfEaIJIbJPuu10=
github.com/projectdiscovery/utils v0.11.12-0.20261025172519-64b3c221cfe5/go.mod h1:TJgsyVjB02CavwZVFzUzPg6YOSJXQxLhL808JCiQKlU=
github.com/projectdiscovery/wappalyzergo v0.3.5 h1:UZradYF1GIcruSIV5wwo/Coph9/VnM17oajLD9eXR2U=
github.com/projectdiscovery/wappalyzergo v0.3.5/go.mod h1:4E6+fh/ckaY3wo5tlM7lNYSkhuTYRHzu2YmGIRraXP0=
github.com/projectdiscovery/yamldoc-go v1.0.7 h1:Odmjd1o4EkMff2F6QHi1dIYPIQPZUFy24YqmQCprmTk=
github.com/projectdiscovery/yamldoc-go v1.0.7/go.mod h1:nqD1iPMcofpRA1DbXavPByJhWURrshT+l8WN9XjyQag=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@@ -1054,8 +1055,8 @@ github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/sandrolain/httpcache v1.4.0 h1:Jf4Vx62X2ybvNPSpPvI1kT3xvMdDG1AsApQjOQKO9E0=
github.com/sandrolain/httpcache v1.4.0/go.mod h1:kHBuXveitSn39SNPBhdf/ybG272X706HJ2RJqOQ+Em0=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c=
github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
-3
View File
@@ -39,9 +39,6 @@ func (r *Runner) initializeTemplatesHTTPInput() (*hybrid.HybridMap, error) {
}
httpxOptions.RetryMax = r.options.Retries
httpxOptions.Timeout = time.Duration(r.options.Timeout) * time.Second
if len(r.options.InternalResolversList) > 0 {
httpxOptions.Resolvers = r.options.InternalResolversList
}
dialers := protocolstate.GetDialersWithId(r.options.ExecutionId)
if dialers == nil {
+1 -35
View File
@@ -2,12 +2,8 @@ package runner
import (
"context"
"errors"
"sync"
"time"
"github.com/projectdiscovery/gologger"
llmclient "github.com/projectdiscovery/nuclei/v3/pkg/operators/common/llm"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
utilsllm "github.com/projectdiscovery/utils/llm"
@@ -32,32 +28,6 @@ func (a *llmMatcherClient) Complete(ctx context.Context, prompt string, asJSON b
})
}
// llmJudgeClient is an llmMatcherClient whose provider classifies natively, so
// llm matchers ask it through Judge instead of Complete.
type llmJudgeClient struct {
*llmMatcherClient
warnComplete sync.Once
}
func (a *llmJudgeClient) Judge(ctx context.Context, question string, labels, inputs []string) (llmclient.Verdict, error) {
verdict, err := a.client.Judge(ctx, utilsllm.JudgeRequest{Question: question, Labels: labels, Inputs: inputs})
if err != nil {
return llmclient.Verdict{}, err
}
return llmclient.Verdict{Label: verdict.Label, Confidence: verdict.Confidence, Model: verdict.Model}, nil
}
// Complete is only reached by llm extractors, which need free text a
// classifier cannot produce. Returning here spends no call budget and
// makes no provider request. Extractors turn the error into an empty
// result, and the warning says why.
func (a *llmJudgeClient) Complete(context.Context, string, bool) (string, error) {
a.warnComplete.Do(func() {
gologger.Warning().Msgf("llm provider %q cannot run llm extractors, they return nothing", utilsllm.ProviderJev)
})
return "", errors.New("llm provider cannot run extractors")
}
// configureLLM builds the scan's llm client from options. It returns nil unless
// -llm is set, so nothing reaches a model until the user opts in. The API key is
// read from the environment by the provider layer, never from options.
@@ -82,9 +52,5 @@ func configureLLM(options *types.Options) (llmclient.Client, error) {
return nil, err
}
matcherClient := &llmMatcherClient{client: client, model: options.LLMModel}
if client.CanJudge() {
return &llmJudgeClient{llmMatcherClient: matcherClient}, nil
}
return matcherClient, nil
return &llmMatcherClient{client: client, model: options.LLMModel}, nil
}
-65
View File
@@ -1,65 +0,0 @@
package runner
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
llmclient "github.com/projectdiscovery/nuclei/v3/pkg/operators/common/llm"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/stretchr/testify/require"
)
func TestConfigureLLMExposesJudgeOnlyForClassifiers(t *testing.T) {
t.Setenv("LLM_API_KEY", "key")
client, err := configureLLM(&types.Options{EnableLLM: true, LLMProvider: "jev", LLMModel: "jev-latest"})
require.NoError(t, err)
require.Implements(t, (*llmclient.Judge)(nil), client)
client, err = configureLLM(&types.Options{EnableLLM: true, LLMProvider: "ollama", LLMModel: "llama3.2"})
require.NoError(t, err)
_, isJudge := client.(llmclient.Judge)
require.False(t, isJudge, "chat providers keep the Complete path")
}
func TestJevExtractorDoesNotSpendCallBudget(t *testing.T) {
t.Setenv("LLM_API_KEY", "key")
// ProxyFromEnvironment caches the first request in the process. Match the
// proxy the interactsh client test expects, and keep the local server direct.
for _, key := range []string{"HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", "https_proxy", "no_proxy"} {
t.Setenv(key, "")
}
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:9")
t.Setenv("NO_PROXY", "127.0.0.1,localhost")
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
_, _ = w.Write([]byte(`{"model":"jev-1.14.0","answers":{"q":{"type":"noul","noul":0.9}}}`))
}))
t.Cleanup(server.Close)
client, err := configureLLM(&types.Options{
EnableLLM: true,
LLMProvider: "jev",
LLMBaseURL: server.URL + "/v1",
LLMModel: "jev-latest",
LLMMaxCalls: 1,
})
require.NoError(t, err)
_, err = client.Complete(context.Background(), "extract the title", true)
require.ErrorContains(t, err, "cannot run extractors")
_, err = client.Complete(context.Background(), "extract another", true)
require.Error(t, err)
require.Zero(t, calls.Load())
judge := client.(llmclient.Judge)
verdict, err := judge.Judge(context.Background(), "login page?", nil, []string{"<form>"})
require.NoError(t, err)
require.Equal(t, "yes", verdict.Label)
require.EqualValues(t, 1, calls.Load())
}
+15 -4
View File
@@ -228,14 +228,25 @@ func ValidateOptions(options *types.Options) error {
}
}
// An empty -iv leaves one unpinned target so the dialer can fall back
// between address families. -sa resolves and scans every selected address.
// Explicit -iv 4, -iv 6 and -iv 4,6 retain their usual selection semantics.
// verify that a valid ip version type was selected (4, 6)
if len(options.IPVersion) == 0 {
// add ipv4 as default
options.IPVersion = append(options.IPVersion, "4")
}
var useIPV4, useIPV6 bool
for _, ipv := range options.IPVersion {
if ipv != "4" && ipv != "6" {
switch ipv {
case "4":
useIPV4 = true
case "6":
useIPV6 = true
default:
return fmt.Errorf("unsupported ip version: %s", ipv)
}
}
if !useIPV4 && !useIPV6 {
return errors.New("ipv4 and/or ipv6 must be selected")
}
return nil
}
-12
View File
@@ -90,15 +90,3 @@ func TestReadsTargetsFromStdin(t *testing.T) {
})
}
}
// The default must remain empty so the provider can distinguish automatic
// IPv6 preference from explicit user-supplied -iv selections.
func TestValidateOptionsIPVersionPreference(t *testing.T) {
automatic := &types.Options{}
require.NoError(t, ValidateOptions(automatic))
require.Empty(t, automatic.IPVersion)
for _, versions := range []goflags.StringSlice{{"4"}, {"6"}, {"4", "6"}} {
require.NoError(t, ValidateOptions(&types.Options{IPVersion: versions}))
}
require.ErrorContains(t, ValidateOptions(&types.Options{IPVersion: goflags.StringSlice{"7"}}), "unsupported ip version")
}
+5 -5
View File
@@ -55,6 +55,7 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/projectfile"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/automaticscan"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/baseline"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/globalmatchers"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/honeypotdetector"
@@ -67,7 +68,6 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/utils/excludematchers"
"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/catchall"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httpclientpool"
"github.com/projectdiscovery/nuclei/v3/pkg/reporting"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
@@ -696,6 +696,10 @@ func (r *Runner) RunEnumeration() error {
executorOpts.ExportReqURLPattern = true
}
if r.options.ConfidenceBaseline {
executorOpts.BaselineCache = baseline.New()
}
if len(r.options.SecretsFile) > 0 && !r.options.Validate {
// Clone options so GetAuthTmplStore can modify them without affecting the original
authOptions := r.options.Copy()
@@ -735,9 +739,6 @@ func (r *Runner) RunEnumeration() error {
if r.options.HostBackoff {
executorOpts.HostBackoff = hostbackoff.New(hostbackoff.Config{Max: r.options.HostBackoffMax})
}
if r.options.AutoCalibrate {
executorOpts.CatchAll = catchall.New(0)
}
executorEngine := core.New(r.options)
executorEngine.SetExecuterOptions(executorOpts)
@@ -1076,7 +1077,6 @@ func (r *Runner) displayExecutionInfo(store *loader.Store) {
stats.DisplayAsWarning(httpProtocol.SetThreadToCountZero)
stats.ForceDisplayWarning(templates.SkippedUnverifiedTemplateStats)
stats.ForceDisplayWarning(templates.SkippedRequestSignatureTemplateStats)
stats.ForceDisplayWarning(templates.SkippedWorkflowStepStats)
cfg := config.DefaultConfig
-66
View File
@@ -6,17 +6,11 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"sync/atomic"
"testing"
"time"
"github.com/miekg/dns"
"github.com/projectdiscovery/gologger"
"github.com/stretchr/testify/require"
"github.com/projectdiscovery/nuclei/v3/pkg/input"
"github.com/projectdiscovery/nuclei/v3/pkg/input/provider"
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
@@ -203,63 +197,3 @@ func TestNewInteractshHTTPClient(t *testing.T) {
require.NotNil(t, proxyURL)
require.Equal(t, "http://127.0.0.1:9", proxyURL.String())
}
func TestInitializeTemplatesHTTPInputUsesResolvers(t *testing.T) {
for _, key := range []string{"HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"} {
t.Setenv(key, "")
}
listener, err := net.ListenPacket("udp", "127.0.0.1:0")
require.NoError(t, err)
queries := new(atomic.Int32)
server := &dns.Server{PacketConn: listener, Handler: dns.HandlerFunc(func(writer dns.ResponseWriter, request *dns.Msg) {
queries.Add(1)
response := new(dns.Msg).SetReply(request)
for _, question := range request.Question {
if question.Qtype == dns.TypeA {
response.Answer = append(response.Answer, &dns.A{Hdr: dns.RR_Header{Name: question.Name, Rrtype: dns.TypeA, Class: dns.ClassINET}, A: net.ParseIP("127.0.0.1")})
}
}
_ = writer.WriteMsg(response)
})}
started := make(chan struct{})
server.NotifyStartedFunc = func() { close(started) }
done := make(chan error, 1)
go func() { done <- server.ActivateAndServe() }()
<-started
t.Cleanup(func() { require.NoError(t, server.Shutdown()); require.NoError(t, <-done) })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
resolverFile := filepath.Join(t.TempDir(), "resolv.conf")
require.NoError(t, os.WriteFile(resolverFile, nil, 0o600))
t.Setenv("RESOLVERS_PATH", resolverFile)
options := &types.Options{
ExecutionId: t.Name(),
Timeout: 5,
BulkSize: 1,
InternalResolversList: []string{listener.LocalAddr().String()},
}
require.NoError(t, protocolstate.Init(options))
t.Cleanup(func() { protocolstate.Close(options.ExecutionId) })
target := "probe.nuclei.test:" + strconv.Itoa(srv.Listener.Addr().(*net.TCPAddr).Port)
r := &Runner{
options: options,
inputProvider: provider.NewSimpleInputProviderWithUrls(options.ExecutionId, target),
Logger: &gologger.Logger{},
}
hm, err := r.initializeTemplatesHTTPInput()
require.NoError(t, err)
t.Cleanup(func() { _ = hm.Close() })
probed, ok := hm.Get(input.ProbeKey(target, ""))
require.True(t, ok, "the probe should resolve the target through the -r resolver")
require.Equal(t, "http://"+target, string(probed))
require.Positive(t, queries.Load())
}
+7 -42
View File
@@ -10,11 +10,9 @@ import (
"github.com/logrusorgru/aurora/v4"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
"github.com/projectdiscovery/nuclei/v3/pkg/model"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/nuclei/v3/pkg/utils"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
)
@@ -40,17 +38,12 @@ func (r *Runner) verboseTemplate(tpl *templates.Template) {
tpl.Info.SeverityHolder.Severity))
}
// listedTemplate names its fields like a result event, so a -jsonl listing
// joins with scan results on the same keys.
type listedTemplate struct {
Template string `json:"template,omitempty"`
TemplateID string `json:"template-id"`
TemplatePath string `json:"template-path"`
Info model.Info `json:"info"`
}
func (r *Runner) listAvailableStoreTemplates(store *loader.Store) {
r.listingHeader("templates")
r.Logger.Print().Msgf(
"\nListing available %v nuclei templates for %v",
config.DefaultConfig.TemplateVersion,
config.DefaultConfig.TemplatesDirectory,
)
// order templates alphabetically by path
templates := store.Templates()
sort.Slice(templates, func(i, j int) bool {
@@ -58,10 +51,6 @@ func (r *Runner) listAvailableStoreTemplates(store *loader.Store) {
})
for _, tpl := range templates {
if r.options.JSONL && !r.options.TemplateDisplay {
r.listTemplateJSON(tpl)
continue
}
if hasExtraFlags(r.options) {
if r.options.TemplateDisplay {
colorize := !r.options.NoColor
@@ -89,36 +78,12 @@ func (r *Runner) listAvailableStoreTemplates(store *loader.Store) {
}
}
func (r *Runner) listTemplateJSON(tpl *templates.Template) {
path, _ := utils.TemplatePathURL(tpl.Path, tpl.ID, "")
marshalled, err := json.Marshal(listedTemplate{
Template: path,
TemplateID: tpl.ID,
TemplatePath: tpl.Path,
Info: tpl.Info,
})
if err != nil {
r.Logger.Error().Msgf("Could not marshal the template %s: %s", tpl.Path, err)
return
}
r.Logger.Print().Msgf("%s", marshalled)
}
// listingHeader is skipped for -jsonl, where every line must parse as JSON.
func (r *Runner) listingHeader(what string) {
if r.options.JSONL {
return
}
func (r *Runner) listAvailableTags(tagsMap map[string]int) {
r.Logger.Print().Msgf(
"\nListing available %v nuclei %s for %v",
"\nListing available %v nuclei tags for %v",
config.DefaultConfig.TemplateVersion,
what,
config.DefaultConfig.TemplatesDirectory,
)
}
func (r *Runner) listAvailableTags(tagsMap map[string]int) {
r.listingHeader("tags")
type kv struct {
Key string `json:"tag"`
-76
View File
@@ -1,76 +0,0 @@
package runner
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/gologger/formatter"
"github.com/projectdiscovery/gologger/levels"
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/disk"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
"github.com/stretchr/testify/require"
)
const listedTemplateYAML = `id: listed-template
info:
name: Listed Template
author: pdteam
severity: info
tags: listing
http:
- method: GET
path:
- "{{BaseURL}}"
`
func TestTemplateListWritesJSONLines(t *testing.T) {
root := t.TempDir()
cfg := config.DefaultConfig
oldRoot := cfg.TemplatesDirectory
t.Cleanup(func() { cfg.SetTemplatesDir(oldRoot) })
cfg.SetTemplatesDir(root)
path := filepath.Join(root, "http", "listed-template.yaml")
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(listedTemplateYAML), 0o600))
writer := &ignoreFileLogWriter{}
logger := &gologger.Logger{}
logger.SetFormatter(formatter.NewCLI(true))
logger.SetWriter(writer)
logger.SetMaxLevel(levels.LevelInfo)
options := testutils.DefaultOptions.Copy()
options.ExecutionId = t.Name()
options.Logger = logger
options.Templates = []string{path}
options.TemplateList = true
options.JSONL = true
testutils.Init(options)
t.Cleanup(func() { testutils.Cleanup(options) })
executerOpts := testutils.NewMockExecuterOptions(options, nil)
executerOpts.Parser = templates.NewParser()
store, err := loader.New(loader.NewConfig(options, disk.NewCatalog(root), executerOpts))
require.NoError(t, err)
require.NoError(t, store.LoadTemplatesOnlyMetadata())
require.Len(t, store.Templates(), 1)
r := &Runner{options: options, Logger: logger}
r.listAvailableStoreTemplates(store)
require.Len(t, writer.records, 1, "JSONL output carries no banner line")
var listed map[string]any
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(writer.records[0].message)), &listed))
require.Equal(t, "listed-template", listed["template-id"])
require.Equal(t, filepath.Join("http", "listed-template.yaml"), listed["template"])
require.Equal(t, "Listed Template", listed["info"].(map[string]any)["name"])
}
-4
View File
@@ -33,7 +33,6 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/utils/excludematchers"
browserEngine "github.com/projectdiscovery/nuclei/v3/pkg/protocols/headless/engine"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/catchall"
"github.com/projectdiscovery/nuclei/v3/pkg/reporting"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
@@ -111,9 +110,6 @@ func newNucleiExecutor(opts *NucleiExecutorOptions) (*nucleiExecutor, error) {
if opts.Options.HostBackoff {
executorOpts.HostBackoff = hostbackoff.New(hostbackoff.Config{Max: opts.Options.HostBackoffMax})
}
if opts.Options.AutoCalibrate {
executorOpts.CatchAll = catchall.New(0)
}
executorEngine := core.New(opts.Options)
executorEngine.SetExecuterOptions(executorOpts)
-57
View File
@@ -102,7 +102,6 @@ var httpTestcases = []integrationCase{
{Path: "protocols/http/raw-unsafe-path-single-slash.yaml", TestCase: &httpRawUnsafePathSingleSlash{}},
{Path: "protocols/http/disable-http-cache.yaml", TestCase: &httpDisableCache{}},
{Path: "protocols/http/http-cache.yaml", TestCase: &httpCache{}},
{Path: "protocols/http/get.yaml", TestCase: &httpExcludeHostsFileComments{}},
}
type httpMultiVarSharing struct{}
@@ -1984,59 +1983,3 @@ func (h *httpDisableCache) Execute(filePath string) error {
return nil
}
// ensure that comments in an -exclude-hosts file are ignored
type httpExcludeHostsFileComments struct{}
// Execute executes a test case and returns an error if occurred
func (h *httpExcludeHostsFileComments) Execute(filePath string) error {
router := httprouter.New()
router.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
_, _ = fmt.Fprintf(w, "This is test matcher text")
})
ts := httptest.NewServer(router)
defer ts.Close()
excludeHosts := func(content string) ([]string, error) {
file, err := os.CreateTemp("", "exclude-hosts-*.txt")
if err != nil {
return nil, err
}
defer func() { _ = os.Remove(file.Name()) }()
_, err = file.WriteString(content)
_ = file.Close()
if err != nil {
return nil, err
}
return testutils.RunNucleiTemplateAndGetResults(filePath, ts.URL, debug, "-eh", file.Name())
}
// a comment line is not a host, even when it reads as an invalid regex
results, err := excludeHosts("# hosts out of scope (see ticket\n203.0.113.10\n")
if err != nil {
return fmt.Errorf("comment line: %w", err)
}
if err := expectResultsCount(results, 1); err != nil {
return fmt.Errorf("comment line: %w", err)
}
// a trailing comment does not stop the host from being excluded
results, err = excludeHosts("127.0.0.1 # test server\n")
if err != nil {
return fmt.Errorf("trailing comment: %w", err)
}
if err := expectResultsCount(results, 0); err != nil {
return fmt.Errorf("trailing comment: %w", err)
}
// comma separated hosts are still split when passed on the command line
results, err = testutils.RunNucleiTemplateAndGetResults(filePath, ts.URL, debug, "-eh", "203.0.113.10, 127.0.0.1")
if err != nil {
return fmt.Errorf("comma separated: %w", err)
}
if err := expectResultsCount(results, 0); err != nil {
return fmt.Errorf("comma separated: %w", err)
}
return nil
}
-3
View File
@@ -358,9 +358,6 @@ func (e *NucleiEngine) init(ctx context.Context) error {
httpxOptions := httpx.DefaultOptions
httpxOptions.Timeout = 5 * time.Second
if len(e.opts.InternalResolversList) > 0 {
httpxOptions.Resolvers = e.opts.InternalResolversList
}
if client, err := httpx.New(&httpxOptions); err != nil {
return err
} else {
+4 -10
View File
@@ -631,10 +631,10 @@ func (store *Store) noteMissingCapabilities(templatePath string, missingCaps []t
// erroring out on discovering any faulty templates.
func (store *Store) ValidateTemplates() error {
templatePaths, errs := store.config.Catalog.GetTemplatesPath(store.finalTemplates)
notFound := store.logErroredTemplates(errs)
store.logErroredTemplates(errs)
workflowPaths, errs := store.config.Catalog.GetTemplatesPath(store.finalWorkflows)
notFound += store.logErroredTemplates(errs)
store.logErroredTemplates(errs)
templatePathsMap := make(map[string]struct{}, len(templatePaths))
for _, path := range templatePaths {
@@ -646,8 +646,7 @@ func (store *Store) ValidateTemplates() error {
workflowPathsMap[path] = struct{}{}
}
// a path that can't be resolved is not validated, so it can't count as valid
if store.areTemplatesValid(templatePathsMap) && store.areWorkflowsValid(workflowPathsMap) && notFound == 0 {
if store.areTemplatesValid(templatePathsMap) && store.areWorkflowsValid(workflowPathsMap) {
return nil
}
@@ -1057,15 +1056,10 @@ func workflowContainsProtocol(workflow []*workflows.WorkflowTemplate) bool {
return false
}
// logErroredTemplates logs the templates that could not be found and returns
// how many of them were not handled by the NotFoundCallback.
func (s *Store) logErroredTemplates(erred map[string]error) int {
notFound := 0
func (s *Store) logErroredTemplates(erred map[string]error) {
for template, err := range erred {
if s.NotFoundCallback == nil || !s.NotFoundCallback(template) {
s.logger.Error().Msgf("Could not find template '%s': %s", template, err)
notFound++
}
}
return notFound
}
+20 -43
View File
@@ -176,8 +176,25 @@ func TestLoadTemplatesOnlyMetadataLogsCachedTemplateParseErrors(t *testing.T) {
require.Contains(t, output.String(), "Could not load template")
}
func newValidateStore(t *testing.T, executionID, templatePath string) (*Store, *bytes.Buffer) {
t.Helper()
func TestValidateTemplatesRejectsRuntimeCompilationError(t *testing.T) {
templatePath := filepath.Join(t.TempDir(), "invalid-regex-group.yaml")
require.NoError(t, os.WriteFile(templatePath, []byte(`id: invalid-regex-group
info:
name: Invalid Regex Group
author: pdteam
severity: info
http:
- method: GET
path:
- "{{BaseURL}}"
extractors:
- type: regex
group: -1
regex:
- "(a)(b)"
`), 0o600))
options := testutils.DefaultOptions.Copy()
var output bytes.Buffer
@@ -186,7 +203,7 @@ func newValidateStore(t *testing.T, executionID, templatePath string) (*Store, *
logger.SetWriter(&utils.CaptureWriter{Buffer: &output})
logger.SetMaxLevel(levels.LevelDebug)
options.Logger = logger
options.ExecutionId = executionID
options.ExecutionId = "loader-invalid-regex-group"
options.Templates = []string{templatePath}
options.Validate = true
options.TemplateLoadingConcurrency = 1
@@ -209,50 +226,10 @@ func newValidateStore(t *testing.T, executionID, templatePath string) (*Store, *
store, err := New(NewConfig(options, catalog, executerOpts))
require.NoError(t, err)
return store, &output
}
func TestValidateTemplatesRejectsRuntimeCompilationError(t *testing.T) {
templatePath := filepath.Join(t.TempDir(), "invalid-regex-group.yaml")
require.NoError(t, os.WriteFile(templatePath, []byte(`id: invalid-regex-group
info:
name: Invalid Regex Group
author: pdteam
severity: info
http:
- method: GET
path:
- "{{BaseURL}}"
extractors:
- type: regex
group: -1
regex:
- "(a)(b)"
`), 0o600))
store, output := newValidateStore(t, "loader-invalid-regex-group", templatePath)
require.Error(t, store.ValidateTemplates())
require.Contains(t, output.String(), "regex extractor group must be >= 0")
}
func TestValidateTemplatesRejectsMissingTemplatePath(t *testing.T) {
templatePath := filepath.Join(t.TempDir(), "no-such-templates")
store, output := newValidateStore(t, "loader-missing-template-path", templatePath)
require.Error(t, store.ValidateTemplates())
require.Contains(t, output.String(), "Could not find template")
}
func TestValidateTemplatesAcceptsMissingPathHandledByCallback(t *testing.T) {
templatePath := filepath.Join(t.TempDir(), "no-such-templates")
store, _ := newValidateStore(t, "loader-missing-path-callback", templatePath)
store.NotFoundCallback = func(string) bool { return true }
require.NoError(t, store.ValidateTemplates())
}
func TestRemoteTemplates(t *testing.T) {
catalog := disk.NewCatalog("")
-1
View File
@@ -5,7 +5,6 @@ Formats implements support for passing a number of request source as input provi
Currently the following formats are implemented -
- Burp Suite XML Request/Response file
- HAR (HTTP Archive) file exported by browsers and proxies
- Proxify JSONL output file
- OpenAPI Specification file
- Postman Collection file
-137
View File
@@ -1,137 +0,0 @@
package har
import (
"encoding/base64"
"io"
"net/http"
"strconv"
"strings"
"github.com/pkg/errors"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats"
"github.com/projectdiscovery/nuclei/v3/pkg/input/types"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
)
// HARFormat is an HTTP Archive (HAR 1.2) parser, the format browsers and
// proxies export captured traffic in.
type HARFormat struct {
opts formats.InputFormatOptions
}
// New creates a new HAR parser
func New() *HARFormat {
return &HARFormat{}
}
var _ formats.Format = &HARFormat{}
type archive struct {
Log struct {
Entries []entry `json:"entries"`
} `json:"log"`
}
type entry struct {
Request struct {
Method string `json:"method"`
URL string `json:"url"`
Headers []header `json:"headers"`
PostData *struct {
Text string `json:"text"`
} `json:"postData"`
} `json:"request"`
Response struct {
Status int `json:"status"`
StatusText string `json:"statusText"`
Headers []header `json:"headers"`
Content struct {
Text string `json:"text"`
Encoding string `json:"encoding"`
} `json:"content"`
} `json:"response"`
}
type header struct {
Name string `json:"name"`
Value string `json:"value"`
}
// Name returns the name of the format
func (h *HARFormat) Name() string {
return "har"
}
func (h *HARFormat) SetOptions(options formats.InputFormatOptions) {
h.opts = options
}
// Parse parses the input and calls the provided callback
// function for each RawRequest it discovers.
func (h *HARFormat) Parse(input io.Reader, resultsCb formats.ParseReqRespCallback, filePath string) error {
var har archive
if err := json.NewDecoder(input).Decode(&har); err != nil {
return errors.Wrap(err, "could not decode har file")
}
for _, e := range har.Log.Entries {
// Browsers also log data:, blob: and extension URLs, which have no
// host to send a request to.
if !strings.HasPrefix(e.Request.URL, "http://") && !strings.HasPrefix(e.Request.URL, "https://") {
continue
}
rawRequest, err := types.ParseRawRequestWithURL(rawRequestOf(e), e.Request.URL)
if err != nil {
gologger.Warning().Msgf("har: Could not parse request %s: %s\n", e.Request.URL, err)
continue
}
// Status 0 is a request that was blocked or never answered.
if e.Response.Status != 0 {
rawRequest.Response = &types.HttpResponse{Raw: rawResponseOf(e)}
}
resultsCb(rawRequest)
}
return nil
}
// rawRequestOf rebuilds the request as HTTP/1.1 with an absolute target, which
// carries the authority that HTTP/2 captures only keep in pseudo-headers.
func rawRequestOf(e entry) string {
var b strings.Builder
b.WriteString(e.Request.Method + " " + e.Request.URL + " HTTP/1.1\r\n")
writeHeaders(&b, e.Request.Headers)
if e.Request.PostData != nil {
b.WriteString(e.Request.PostData.Text)
}
return b.String()
}
func rawResponseOf(e entry) string {
statusText := e.Response.StatusText
if statusText == "" {
statusText = http.StatusText(e.Response.Status)
}
var b strings.Builder
b.WriteString("HTTP/1.1 " + strconv.Itoa(e.Response.Status) + " " + statusText + "\r\n")
writeHeaders(&b, e.Response.Headers)
body := e.Response.Content.Text
if e.Response.Content.Encoding == "base64" {
if decoded, err := base64.StdEncoding.DecodeString(body); err == nil {
body = string(decoded)
}
}
b.WriteString(body)
return b.String()
}
func writeHeaders(b *strings.Builder, headers []header) {
for _, h := range headers {
// HTTP/2 pseudo-headers (:authority, :path, ...) are not headers.
if strings.HasPrefix(h.Name, ":") {
continue
}
b.WriteString(h.Name + ": " + h.Value + "\r\n")
}
b.WriteString("\r\n")
}
-71
View File
@@ -1,71 +0,0 @@
package har
import (
"os"
"strings"
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/input/types"
"github.com/stretchr/testify/require"
)
const chromeHAR = "../testdata/chrome.har"
func parse(t *testing.T, input string) []*types.RequestResponse {
t.Helper()
file, err := os.Open(input)
require.NoError(t, err)
defer func() { _ = file.Close() }()
var got []*types.RequestResponse
require.NoError(t, New().Parse(file, func(rr *types.RequestResponse) bool {
got = append(got, rr)
return false
}, input))
return got
}
func TestHARParse(t *testing.T) {
got := parse(t, chromeHAR)
urls := make([]string, 0, len(got))
for _, rr := range got {
urls = append(urls, rr.URL.String())
}
require.Equal(t, []string{
"https://ginandjuice.shop/catalog?category=Juice",
"https://ginandjuice.shop/login",
"https://ginandjuice.shop/blocked.js",
}, urls, "non-http entries such as data: URLs are skipped")
}
func TestHARParseHTTP2Request(t *testing.T) {
rr := parse(t, chromeHAR)[0]
require.Equal(t, "GET", rr.Request.Method)
cookie, _ := rr.Request.Headers.Get("cookie")
require.Equal(t, "session=abc123", cookie)
for _, name := range rr.Request.Headers.GetKeys() {
require.False(t, strings.HasPrefix(name, ":"), "pseudo-header %s is not a real header", name)
}
req, err := rr.BuildRequest()
require.NoError(t, err)
require.Equal(t, "ginandjuice.shop", req.Host)
}
func TestHARParseBodyAndResponse(t *testing.T) {
got := parse(t, chromeHAR)
login := got[1]
require.Equal(t, "POST", login.Request.Method)
require.Equal(t, "username=carlos&password=hunter2", login.Request.Body)
require.NotNil(t, login.Response)
require.Contains(t, login.Response.Raw, "302 Found")
require.Contains(t, login.Response.Raw, "Location: /my-account")
require.NotNil(t, got[0].Response)
require.Contains(t, got[0].Response.Raw, "<h1>Juice</h1>", "base64 content is decoded")
require.Nil(t, got[2].Response, "a request that never got an answer has no response")
}
-20
View File
@@ -1,20 +0,0 @@
//go:build gofuzz
// +build gofuzz
package openapi
// Fuzz exercises OpenAPI 3.0 schema parsing and request generation. The input
// bytes are treated as a raw schema document (YAML or JSON) and fed through the
// format's Parse, which loads the schema and generates requests from it.
func Fuzz(data []byte) int {
if len(data) == 0 {
return 0
}
if len(data) > fuzzMaxInputSize {
return -1
}
if !fuzzOpenAPIParsing(data) {
return 0
}
return 1
}
-32
View File
@@ -1,32 +0,0 @@
package openapi
import (
"bytes"
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats"
"github.com/projectdiscovery/nuclei/v3/pkg/input/types"
)
const fuzzMaxInputSize = 64 << 10
// fuzzOpenAPIParsing feeds data to the OpenAPI format parser as a raw schema
// document and drives request generation. It must never panic for any input;
// it returns true when the schema parsed and at least one request was produced.
func fuzzOpenAPIParsing(data []byte) bool {
if len(data) == 0 || len(data) > fuzzMaxInputSize {
return false
}
format := New()
format.SetOptions(formats.InputFormatOptions{})
produced := false
cb := func(rr *types.RequestResponse) bool {
produced = true
return true
}
if err := format.Parse(bytes.NewReader(data), cb, "fuzz.openapi"); err != nil {
return false
}
return produced
}
@@ -1,84 +0,0 @@
package openapi
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/input/types"
"github.com/stretchr/testify/require"
)
func TestFuzzOpenAPIParsingSeedCorpus(t *testing.T) {
entries, err := os.ReadDir("testdata/gofuzz-corpus")
require.NoError(t, err)
require.NotEmpty(t, entries)
for _, entry := range entries {
if entry.IsDir() {
continue
}
data, err := os.ReadFile(filepath.Join("testdata/gofuzz-corpus", entry.Name()))
require.NoError(t, err)
require.Truef(t, fuzzOpenAPIParsing(data), "seed %s should produce at least one request", entry.Name())
}
}
func TestFuzzOpenAPIParsingRejectsOversizeInput(t *testing.T) {
require.False(t, fuzzOpenAPIParsing(make([]byte, fuzzMaxInputSize+1)))
}
// Null entries decode to nil pointers and used to panic during request
// generation. The remaining valid entries must still produce requests.
func TestFuzzOpenAPIParsingNullEntries(t *testing.T) {
specs := map[string]string{
"null server": `
openapi: 3.0.0
info: {title: t, version: "1"}
servers:
-
- {url: "http://example.com"}
paths:
/users:
get:
responses: {"200": {description: ok}}
`,
"null media type": `
openapi: 3.0.0
info: {title: t, version: "1"}
servers: [{url: "http://example.com"}]
paths:
/users:
post:
requestBody:
content:
application/xml:
application/json:
schema: {type: object, properties: {name: {type: string}}}
responses: {"200": {description: ok}}
`,
}
for name, spec := range specs {
t.Run(name, func(t *testing.T) {
require.True(t, fuzzOpenAPIParsing([]byte(spec)))
})
}
}
func TestGenerateRequestsOnlyNullServers(t *testing.T) {
spec := `
openapi: 3.0.0
info: {title: t, version: "1"}
servers:
-
paths:
/users:
get:
responses: {"200": {description: ok}}
`
err := New().Parse(strings.NewReader(spec), func(*types.RequestResponse) bool { return false }, "spec.yaml")
require.ErrorContains(t, err, "no servers found in openapi schema")
}
+1 -14
View File
@@ -9,7 +9,6 @@ import (
"net/http/httputil"
"net/url"
"os"
"slices"
"strings"
"github.com/clbanning/mxj/v2"
@@ -34,9 +33,7 @@ const (
// GenerateRequestsFromSchema generates http requests from an OpenAPI 3.0 document object
func GenerateRequestsFromSchema(schema *openapi3.T, opts formats.InputFormatOptions, callback formats.ParseReqRespCallback) error {
// Null entries in `servers:` are skipped below, so a list of only null
// entries has no usable server, the same as an empty list.
if !slices.ContainsFunc(schema.Servers, func(s *openapi3.Server) bool { return s != nil }) {
if len(schema.Servers) == 0 {
return errors.New("no servers found in openapi schema")
}
globalParams, globalSecurityErr := selectSecurityParameters(schema, &schema.Security, opts.Variables)
@@ -52,11 +49,6 @@ func GenerateRequestsFromSchema(schema *openapi3.T, opts formats.InputFormatOpti
}
for _, serverURL := range schema.Servers {
// A null entry in the `servers:` list decodes to a nil *Server, so skip
// it rather than dereferencing it.
if serverURL == nil {
continue
}
pathURL := serverURL.URL
// Split the server URL into baseURL and serverPath
u, err := url.Parse(pathURL)
@@ -276,11 +268,6 @@ func generateRequestsFromOp(opts *generateReqOptions) error {
if opts.op.RequestBody != nil {
for content, value := range opts.op.RequestBody.Value.Content {
// A null media-type entry (e.g. `content: {application/json: }`)
// decodes to a nil *MediaType, so skip it before dereferencing.
if value == nil {
continue
}
cloned := req.Clone(req.Context())
var val interface{}
@@ -1 +0,0 @@
{"openapi":"3.0.0","info":{"title":"t","version":"1"},"servers":[{"url":"http://example.com"}],"paths":{"/a":{"post":{"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"k":{"type":"string"}}}}}},"responses":{"200":{"description":"ok"}}}}}}
@@ -1,7 +0,0 @@
openapi: 3.0.0
info: {title: t, version: "1"}
servers: [{url: "http://example.com"}]
paths:
/users:
get:
responses: {"200": {description: ok}}
@@ -1,12 +0,0 @@
openapi: 3.0.0
info: {title: t, version: "1"}
servers: [{url: "http://example.com"}]
paths:
/users:
post:
requestBody:
content:
application/xml:
application/json:
schema: {type: object, properties: {name: {type: string}}}
responses: {"200": {description: ok}}
@@ -1,9 +0,0 @@
openapi: 3.0.0
info: {title: t, version: "1"}
servers:
-
- {url: "http://example.com"}
paths:
/users:
get:
responses: {"200": {description: ok}}
-29
View File
@@ -3,9 +3,7 @@ package swagger
import (
"fmt"
"io"
"maps"
"path"
"slices"
"github.com/getkin/kin-openapi/openapi2"
"github.com/getkin/kin-openapi/openapi2conv"
@@ -57,7 +55,6 @@ func (j *SwaggerFormat) Parse(input io.Reader, resultsCb formats.ParseReqRespCal
if err != nil {
return errors.Wrap(err, "could not decode openapi 2.0 schema")
}
dropNullEntries(schemav2)
schema, err := openapi2conv.ToV3(schemav2)
if err != nil {
return errors.Wrap(err, "could not convert openapi 2.0 schema to 3.0")
@@ -70,32 +67,6 @@ func (j *SwaggerFormat) Parse(input io.Reader, resultsCb formats.ParseReqRespCal
return openapi.GenerateRequestsFromSchema(schema, j.opts, resultsCb)
}
// dropNullEntries removes null entries from the maps and lists of a decoded
// OpenAPI 2.0 document. A null value such as `paths: {/x: }` or an empty list
// item under `parameters:` decodes to a nil pointer, which openapi2conv.ToV3
// dereferences without checking.
func dropNullEntries(doc *openapi2.T) {
maps.DeleteFunc(doc.Paths, func(_ string, v *openapi2.PathItem) bool { return v == nil })
maps.DeleteFunc(doc.Parameters, func(_ string, v *openapi2.Parameter) bool { return v == nil })
dropNullResponses(doc.Responses)
for _, pathItem := range doc.Paths {
pathItem.Parameters = slices.DeleteFunc(pathItem.Parameters, func(v *openapi2.Parameter) bool { return v == nil })
for _, op := range pathItem.Operations() {
op.Parameters = slices.DeleteFunc(op.Parameters, func(v *openapi2.Parameter) bool { return v == nil })
dropNullResponses(op.Responses)
}
}
}
// dropNullResponses removes null responses, and null headers of the remaining
// responses, which openapi2conv.ToV3Response also dereferences.
func dropNullResponses(responses map[string]*openapi2.Response) {
maps.DeleteFunc(responses, func(_ string, v *openapi2.Response) bool { return v == nil })
for _, response := range responses {
maps.DeleteFunc(response.Headers, func(_ string, v *openapi2.Header) bool { return v == nil })
}
}
func decodeYAML(data []byte, target interface{}) error {
var value interface{}
if err := yaml.Unmarshal(data, &value); err != nil {
-67
View File
@@ -2,7 +2,6 @@ package swagger
import (
"os"
"strings"
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/input/types"
@@ -40,69 +39,3 @@ func TestSwaggerAPIParser(t *testing.T) {
}
require.ElementsMatch(t, gotMethodsToURLs, expectedURLs, "could not get swagger urls")
}
func TestSwaggerParseNullEntries(t *testing.T) {
// Each spec has a null map value or list item that decodes to a nil
// pointer and used to panic inside openapi2conv.ToV3.
specs := map[string]string{
"null-path-item": `
swagger: "2.0"
info: {title: t, version: "1"}
host: localhost
paths:
/x:
`,
"null-parameter": `
swagger: "2.0"
info: {title: t, version: "1"}
host: localhost
paths:
/x:
get:
parameters:
-
responses: {"200": {description: ok}}
`,
"null-response": `
swagger: "2.0"
info: {title: t, version: "1"}
host: localhost
paths:
/x:
get:
responses: {"200": }
`,
"null-response-header": `
swagger: "2.0"
info: {title: t, version: "1"}
host: localhost
responses:
Shared:
description: ok
headers:
X-Shared:
paths:
/x:
get:
responses:
"200":
description: ok
headers:
X-Rate-Limit:
`,
}
for name, spec := range specs {
t.Run(name, func(t *testing.T) {
var count int
err := New().Parse(strings.NewReader(spec), func(request *types.RequestResponse) bool {
count++
return false
}, "spec.yaml")
require.NoError(t, err)
if name != "null-path-item" {
require.Equal(t, 1, count, "the operation should still produce a request")
}
})
}
}
-71
View File
@@ -1,71 +0,0 @@
{
"log": {
"version": "1.2",
"creator": { "name": "WebInspector", "version": "537.36" },
"entries": [
{
"request": {
"method": "GET",
"url": "https://ginandjuice.shop/catalog?category=Juice",
"httpVersion": "http/2.0",
"headers": [
{ "name": ":authority", "value": "ginandjuice.shop" },
{ "name": ":method", "value": "GET" },
{ "name": ":path", "value": "/catalog?category=Juice" },
{ "name": ":scheme", "value": "https" },
{ "name": "accept", "value": "text/html" },
{ "name": "cookie", "value": "session=abc123" }
],
"queryString": [{ "name": "category", "value": "Juice" }],
"cookies": [],
"headersSize": -1,
"bodySize": 0
},
"response": {
"status": 200,
"statusText": "",
"httpVersion": "http/2.0",
"headers": [{ "name": "content-type", "value": "text/html; charset=utf-8" }],
"content": { "size": 19, "mimeType": "text/html", "text": "PGgxPkp1aWNlPC9oMT4=", "encoding": "base64" }
}
},
{
"request": {
"method": "POST",
"url": "https://ginandjuice.shop/login",
"httpVersion": "HTTP/1.1",
"headers": [
{ "name": "Host", "value": "ginandjuice.shop" },
{ "name": "Content-Type", "value": "application/x-www-form-urlencoded" }
],
"postData": { "mimeType": "application/x-www-form-urlencoded", "text": "username=carlos&password=hunter2" }
},
"response": {
"status": 302,
"statusText": "Found",
"httpVersion": "HTTP/1.1",
"headers": [{ "name": "Location", "value": "/my-account" }],
"content": { "size": 0, "mimeType": "" }
}
},
{
"request": {
"method": "GET",
"url": "data:image/png;base64,iVBORw0KGgo=",
"httpVersion": "",
"headers": []
},
"response": { "status": 200, "statusText": "", "httpVersion": "", "headers": [], "content": { "size": 0 } }
},
{
"request": {
"method": "GET",
"url": "https://ginandjuice.shop/blocked.js",
"httpVersion": "",
"headers": []
},
"response": { "status": 0, "statusText": "", "httpVersion": "", "headers": [], "content": { "size": 0 } }
}
]
}
}
-2
View File
@@ -10,7 +10,6 @@ import (
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats"
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats/burp"
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats/har"
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats/json"
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats/openapi"
"github.com/projectdiscovery/nuclei/v3/pkg/input/formats/raw"
@@ -143,7 +142,6 @@ func (i *HttpInputProvider) Close() {}
// Supported Providers
var providersList = []formats.Format{
burp.New(),
har.New(),
json.New(),
yaml.New(),
openapi.New(),
+138 -66
View File
@@ -89,9 +89,8 @@ func New(opts *Options) (*ListInputProvider, error) {
patterns: normalize.NewFingerprinter(options.MaxURLsPerPattern),
ipOptions: &ipOptions{
ScanAllIPs: options.ScanAllIPs,
IPV4: len(options.IPVersion) == 0 || sliceutil.Contains(options.IPVersion, "4"),
IPV6: len(options.IPVersion) == 0 || sliceutil.Contains(options.IPVersion, "6"),
Auto: len(options.IPVersion) == 0,
IPV4: sliceutil.Contains(options.IPVersion, "4"),
IPV6: sliceutil.Contains(options.IPVersion, "6"),
},
excludedHosts: make(map[string]struct{}),
profiles: targetprofile.NewRegistry(configuration.TemplatesDirectory(options, config.DefaultConfig)),
@@ -169,61 +168,6 @@ func (i *ListInputProvider) TargetProfiles() *targetprofile.Registry {
return i.profiles
}
// selectedIPs returns address pins for a hostname. An empty pin delegates
// DNS and connection fallback to fastdialer, which races IPv4 and IPv6.
// Set and Del share this selection. With -iv omitted and without -sa, one
// unpinned target is returned. -sa returns every selected address.
func (i *ListInputProvider) selectedIPs(executionId, hostname string) []string {
if i.ipOptions == nil || (!i.ipOptions.ScanAllIPs && (i.ipOptions.Auto || !i.ipOptions.IPV6)) {
return []string{""}
}
dialers := protocolstate.GetDialersWithId(executionId)
if dialers == nil {
gologger.Warning().Msgf("cannot resolve %s: dialer %s not found", hostname, executionId)
return nil
}
dnsData, err := dialers.Fastdialer.GetDNSData(hostname)
if err != nil {
gologger.Warning().Msgf("DNS resolution failed for %s: %v", hostname, err)
if i.ipOptions.IPV4 {
return []string{""}
}
return nil
}
if len(dnsData.A) == 0 && len(dnsData.AAAA) == 0 {
gologger.Warning().Msgf("DNS resolution returned no IP addresses for %s", hostname)
if i.ipOptions.IPV4 {
return []string{""}
}
return nil
}
if i.ipOptions.ScanAllIPs {
var ips []string
if i.ipOptions.IPV6 {
ips = append(ips, dnsData.AAAA...)
}
if i.ipOptions.IPV4 {
ips = append(ips, dnsData.A...)
}
if len(ips) == 0 {
gologger.Warning().Msgf("no DNS addresses matching -iv selection for %s", hostname)
}
return ips
}
var ips []string
if i.ipOptions.IPV6 && len(dnsData.AAAA) > 0 {
ips = append(ips, dnsData.AAAA[0])
}
// Explicit -iv 4,6 scans the first IPv6 address and one unpinned target.
if i.ipOptions.IPV4 {
ips = append(ips, "")
}
if len(ips) == 0 {
gologger.Warning().Msgf("no IPv6 DNS address found for %s", hostname)
}
return ips
}
// Set normalizes and stores passed input values
func (i *ListInputProvider) Set(executionId string, value string) {
i.set(executionId, value, nil)
@@ -258,11 +202,75 @@ func (i *ListInputProvider) set(executionId string, value string, selection *tar
return
}
for _, ip := range i.selectedIPs(executionId, urlx.Hostname()) {
if i.ipOptions.ScanAllIPs {
// scan all ips
dialers := protocolstate.GetDialersWithId(executionId)
if dialers == nil {
gologger.Error().Msgf("dialers with executionId %s not found", executionId)
return
}
dnsData, err := dialers.Fastdialer.GetDNSData(urlx.Hostname())
if err == nil {
if (len(dnsData.A) + len(dnsData.AAAA)) > 0 {
var ips []string
if i.ipOptions.IPV4 {
ips = append(ips, dnsData.A...)
}
if i.ipOptions.IPV6 {
ips = append(ips, dnsData.AAAA...)
}
for _, ip := range ips {
if ip == "" {
continue
}
metaInput := contextargs.NewMetaInput()
metaInput.Input = URL
metaInput.CustomIP = ip
i.setItem(metaInput, selection)
}
return
} else {
gologger.Debug().Msgf("scanAllIps: no ip's found reverting to default")
}
} else {
// failed to scanallips falling back to defaults
gologger.Debug().Msgf("scanAllIps: dns resolution failed: %v", err)
}
}
ips := []string{}
// only scan the target but ipv6 if it has one
if i.ipOptions.IPV6 {
dialers := protocolstate.GetDialersWithId(executionId)
if dialers == nil {
gologger.Error().Msgf("dialers with executionId %s not found", executionId)
return
}
dnsData, err := dialers.Fastdialer.GetDNSData(urlx.Hostname())
if err == nil && len(dnsData.AAAA) > 0 {
// pick/ prefer 1st
ips = append(ips, dnsData.AAAA[0])
} else {
gologger.Warning().Msgf("target does not have ipv6 address falling back to ipv4 %v\n", err)
}
}
if i.ipOptions.IPV4 {
// if IPV4 is enabled do not specify ip let dialer handle it
ips = append(ips, "")
}
for _, ip := range ips {
metaInput := contextargs.NewMetaInput()
metaInput.Input = URL
metaInput.CustomIP = ip
i.setItem(metaInput, selection)
if ip != "" {
metaInput.Input = URL
metaInput.CustomIP = ip
i.setItem(metaInput, selection)
} else {
metaInput.Input = URL
i.setItem(metaInput, selection)
}
}
}
@@ -485,11 +493,75 @@ func (i *ListInputProvider) Del(executionId string, value string) {
return
}
for _, ip := range i.selectedIPs(executionId, urlx.Hostname()) {
if i.ipOptions.ScanAllIPs {
// scan all ips
dialers := protocolstate.GetDialersWithId(executionId)
if dialers == nil {
gologger.Error().Msgf("dialers with executionId %s not found", executionId)
return
}
dnsData, err := dialers.Fastdialer.GetDNSData(urlx.Hostname())
if err == nil {
if (len(dnsData.A) + len(dnsData.AAAA)) > 0 {
var ips []string
if i.ipOptions.IPV4 {
ips = append(ips, dnsData.A...)
}
if i.ipOptions.IPV6 {
ips = append(ips, dnsData.AAAA...)
}
for _, ip := range ips {
if ip == "" {
continue
}
metaInput := contextargs.NewMetaInput()
metaInput.Input = value
metaInput.CustomIP = ip
i.delItem(metaInput)
}
return
} else {
gologger.Debug().Msgf("scanAllIps: no ip's found reverting to default")
}
} else {
// failed to scanallips falling back to defaults
gologger.Debug().Msgf("scanAllIps: dns resolution failed: %v", err)
}
}
ips := []string{}
// only scan the target but ipv6 if it has one
if i.ipOptions.IPV6 {
dialers := protocolstate.GetDialersWithId(executionId)
if dialers == nil {
gologger.Error().Msgf("dialers with executionId %s not found", executionId)
return
}
dnsData, err := dialers.Fastdialer.GetDNSData(urlx.Hostname())
if err == nil && len(dnsData.AAAA) > 0 {
// pick/ prefer 1st
ips = append(ips, dnsData.AAAA[0])
} else {
gologger.Warning().Msgf("target does not have ipv6 address falling back to ipv4 %v\n", err)
}
}
if i.ipOptions.IPV4 {
// if IPV4 is enabled do not specify ip let dialer handle it
ips = append(ips, "")
}
for _, ip := range ips {
metaInput := contextargs.NewMetaInput()
metaInput.Input = URL
metaInput.CustomIP = ip
i.delItem(metaInput)
if ip != "" {
metaInput.Input = URL
metaInput.CustomIP = ip
i.delItem(metaInput)
} else {
metaInput.Input = URL
i.delItem(metaInput)
}
}
}
-106
View File
@@ -1,106 +0,0 @@
package list
import (
"net"
"strings"
"testing"
"time"
"github.com/miekg/dns"
"github.com/projectdiscovery/hmap/store/hybrid"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/stretchr/testify/require"
)
// A local deterministic DNS responder for dual-stack, A-only and AAAA-only hosts.
type ipVersionDNSHandler struct{}
func (ipVersionDNSHandler) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
reply := new(dns.Msg)
reply.SetReply(r)
if len(r.Question) == 0 {
_ = w.WriteMsg(reply)
return
}
question := r.Question[0]
// Some resolvers try search suffixes before the absolute name. Match the
// test host prefix rather than answering unrelated names as dual-stack.
host := strings.ToLower(question.Name)
if strings.HasPrefix(host, "empty.") {
_ = w.WriteMsg(reply)
return
}
isDual := strings.HasPrefix(host, "dual.")
isV4 := strings.HasPrefix(host, "v4only.")
isV6 := strings.HasPrefix(host, "v6only.")
if question.Qtype == dns.TypeA && (isDual || isV4) {
reply.Answer = append(reply.Answer, &dns.A{Hdr: dns.RR_Header{Name: question.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, A: net.ParseIP("192.0.2.10")})
}
if question.Qtype == dns.TypeAAAA && (isDual || isV6) {
reply.Answer = append(reply.Answer, &dns.AAAA{Hdr: dns.RR_Header{Name: question.Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 60}, AAAA: net.ParseIP("2001:db8::10")})
}
_ = w.WriteMsg(reply)
}
func TestIPVersionsDefaultAndExplicitSelection(t *testing.T) {
conn, err := net.ListenPacket("udp", "127.0.0.1:0")
require.NoError(t, err)
started := make(chan struct{})
server := &dns.Server{PacketConn: conn, Handler: ipVersionDNSHandler{}, NotifyStartedFunc: func() { close(started) }}
go func() { _ = server.ActivateAndServe() }()
t.Cleanup(func() { _ = server.Shutdown() })
select {
case <-started:
case <-time.After(5 * time.Second):
t.Fatal("DNS test resolver did not start")
}
opts := types.DefaultOptions()
opts.ExecutionId = "ip-version-regression"
opts.InternalResolversList = []string{conn.LocalAddr().String()}
require.NoError(t, protocolstate.Init(opts))
t.Cleanup(func() { protocolstate.Close(opts.ExecutionId) })
for _, tc := range []struct {
name, hostname string
scanAll, ipv4, ipv6, auto bool
want []string
}{
{"automatic dual-stack keeps dialer fallback", "dual.test", false, true, true, true, []string{""}},
{"automatic IPv6-only keeps dialer fallback", "v6only.test", false, true, true, true, []string{""}},
{"auto IPv4 fallback", "v4only.test", false, true, true, true, []string{""}},
{"auto empty DNS response fallback", "empty.test", false, true, true, true, []string{""}},
{"all empty DNS response fallback", "empty.test", true, true, true, true, []string{""}},
{"all dual", "dual.test", true, true, true, true, []string{"192.0.2.10", "2001:db8::10"}},
{"all IPv6 only", "v6only.test", true, true, true, true, []string{"2001:db8::10"}},
{"all IPv4 only", "v4only.test", true, true, true, true, []string{"192.0.2.10"}},
{"explicit IPv4 only", "dual.test", false, true, false, false, []string{""}},
{"explicit IPv6 only", "dual.test", false, false, true, false, []string{"2001:db8::10"}},
{"explicit both", "dual.test", false, true, true, false, []string{"2001:db8::10", ""}},
{"all explicit IPv6", "dual.test", true, false, true, false, []string{"2001:db8::10"}},
{"all explicit IPv4", "dual.test", true, true, false, false, []string{"192.0.2.10"}},
{"IPv6 restricted on IPv4-only", "v4only.test", true, false, true, false, nil},
} {
t.Run(tc.name, func(t *testing.T) {
hm, err := hybrid.New(hybrid.DefaultDiskOptions)
require.NoError(t, err)
input := &ListInputProvider{
hostMap: hm,
excludedHosts: make(map[string]struct{}),
ipOptions: &ipOptions{ScanAllIPs: tc.scanAll, IPV4: tc.ipv4, IPV6: tc.ipv6, Auto: tc.auto},
}
t.Cleanup(input.Close)
input.Set(opts.ExecutionId, "https://"+tc.hostname)
var got []string
input.Iterate(func(value *contextargs.MetaInput) bool {
got = append(got, value.CustomIP)
return true
})
require.ElementsMatch(t, tc.want, got)
if tc.scanAll && len(tc.want) > 0 {
input.Del(opts.ExecutionId, "https://"+tc.hostname)
require.EqualValues(t, 0, input.Count(), "Del must remove the same IPs added by Set")
}
})
}
}
-1
View File
@@ -4,5 +4,4 @@ type ipOptions struct {
ScanAllIPs bool
IPV4 bool
IPV6 bool
Auto bool // -iv omitted: one unpinned target unless -sa scans every address
}
-2
View File
@@ -16,12 +16,10 @@ import (
syncutil "github.com/projectdiscovery/utils/sync"
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libbytes"
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libcompress"
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libdcerpc"
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libdcom"
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libfs"
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libgrpc"
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libhash"
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libhttp"
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libikev2"
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libkerberos"
-158
View File
@@ -1,158 +0,0 @@
package compiler
import (
"bytes"
"fmt"
"io"
"math/rand"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"time"
"github.com/andybalholm/brotli"
"github.com/klauspost/compress/gzip"
"github.com/klauspost/compress/zlib"
"github.com/klauspost/compress/zstd"
"github.com/projectdiscovery/dsl"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
httputil "github.com/projectdiscovery/utils/http"
"github.com/stretchr/testify/require"
)
const streamHashScript = `
const net = require('nuclei/net');
const compress = require('nuclei/compress');
const hash = require('nuclei/hash');
const conn = net.Open('tcp', %q);
conn.Send('GET / HTTP/1.1\r\nHost: test\r\nAccept-Encoding: gzip, deflate, br, zstd\r\n\r\n');
let head = '';
while (!head.endsWith('\r\n\r\n')) head += conn.RecvString(1);
const header = (name) => (head.match(new RegExp('\r\n' + name + ': ([^\r]+)', 'i')) || [])[1];
const length = header('Content-Length');
const coding = [header('Content-Encoding'), header('Transfer-Encoding')].filter(Boolean).join(', ');
const d = compress.NewDecoder(coding);
const sha = hash.New('sha256'), mmh3 = hash.New('mmh3');
const take = (out) => { sha.Write(out); mmh3.Write(out); };
if (length !== undefined) {
for (let read = 0; read < parseInt(length); ) {
const chunk = conn.Recv(65536);
read += chunk.length;
for (let out = d.Write(chunk); out.length > 0; out = d.Read()) take(out);
}
for (let out = d.Close(); out.length > 0; out = d.Read()) take(out);
} else {
while (!d.Finished()) {
for (let out = d.Write(conn.Recv(65536)); out.length > 0; out = d.Read()) take(out);
}
}
ExportAs('sha256', sha.Sum());
ExportAs('mmh3', mmh3.Sum());
true;
`
func encodeBody(t *testing.T, encoding string, data []byte) []byte {
t.Helper()
var buf bytes.Buffer
var w io.WriteCloser
switch encoding {
case "gzip":
w = gzip.NewWriter(&buf)
case "deflate":
w = zlib.NewWriter(&buf)
case "br":
w = brotli.NewWriter(&buf)
case "zstd":
zw, err := zstd.NewWriter(&buf)
require.NoError(t, err)
w = zw
case "":
return data
}
_, err := w.Write(data)
require.NoError(t, err)
require.NoError(t, w.Close())
return buf.Bytes()
}
func TestStreamingHashMatchesHTTPEngine(t *testing.T) {
rng := rand.New(rand.NewSource(1))
var body []byte
for len(body) < 6<<20 {
body = append(body, "large compressed response body "...)
body = append(body, byte(rng.Intn(256)))
}
executionID := t.Name()
require.NoError(t, protocolstate.Init(&types.Options{ExecutionId: executionID}))
t.Cleanup(func() { protocolstate.Close(executionID) })
for _, tc := range []struct {
encoding string
chunked bool
}{
{"gzip", false},
{"gzip", true},
{"deflate", false},
{"br", true},
{"zstd", true},
{"", true},
} {
t.Run(fmt.Sprintf("%s chunked=%v", tc.encoding, tc.chunked), func(t *testing.T) {
encoded := encodeBody(t, tc.encoding, body)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if tc.encoding != "" {
w.Header().Set("Content-Encoding", tc.encoding)
}
if !tc.chunked {
w.Header().Set("Content-Length", strconv.Itoa(len(encoded)))
_, _ = w.Write(encoded)
return
}
// Flushing before the handler returns makes the body chunked.
for rest := encoded; len(rest) > 0; {
n := min(len(rest), 50000)
_, _ = w.Write(rest[:n])
w.(http.Flusher).Flush()
rest = rest[n:]
}
}))
defer server.Close()
client := &http.Client{Transport: &http.Transport{DisableCompression: true}}
resp, err := client.Get(server.URL)
require.NoError(t, err)
if tc.chunked {
require.Equal(t, []string{"chunked"}, resp.TransferEncoding)
}
chain := httputil.NewResponseChain(resp, 64<<20)
defer chain.Close()
require.NoError(t, chain.Fill())
require.Equal(t, len(body), chain.Body().Len(), "the engine must decode the whole body")
engineBody := map[string]interface{}{"body": chain.BodyString()}
wantSha, err := dsl.EvalExpr("sha256(body)", engineBody)
require.NoError(t, err)
wantMmh3, err := dsl.EvalExpr("mmh3(body)", engineBody)
require.NoError(t, err)
serverURL, err := url.Parse(server.URL)
require.NoError(t, err)
script := fmt.Sprintf(streamHashScript, serverURL.Host)
compiled, err := SourceAutoMode(script, false)
require.NoError(t, err)
result, err := New().ExecuteWithOptions(t.Context(), compiled, NewExecuteArgs(), &ExecuteOptions{
ExecutionId: executionID,
Source: &script,
TimeoutVariants: &types.Timeouts{JsCompilerExecutionTimeout: 20 * time.Second},
})
require.NoError(t, err)
require.Equal(t, fmt.Sprint(wantSha), result["sha256"])
require.Equal(t, fmt.Sprint(wantMmh3), result["mmh3"])
})
}
}
@@ -1,30 +0,0 @@
package compress
import (
lib_compress "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/compress"
"github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
var (
module = gojs.NewGojaModule("nuclei/compress")
)
func init() {
module.Set(
gojs.Objects{
// Functions
"NewDecoder": lib_compress.NewDecoder,
// Var and consts
// Objects / Classes
"Decoder": gojs.GetClassConstructor[lib_compress.Decoder](&lib_compress.Decoder{}),
},
).Register()
}
func Enable(runtime *goja.Runtime) {
module.Enable(runtime)
}
-30
View File
@@ -1,30 +0,0 @@
package hash
import (
lib_hash "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/hash"
"github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
var (
module = gojs.NewGojaModule("nuclei/hash")
)
func init() {
module.Set(
gojs.Objects{
// Functions
"New": lib_hash.New,
// Var and consts
// Objects / Classes
"Hash": gojs.GetClassConstructor[lib_hash.Hash](&lib_hash.Hash{}),
},
).Register()
}
func Enable(runtime *goja.Runtime) {
module.Enable(runtime)
}
-102
View File
@@ -1,102 +0,0 @@
/**
* NewDecoder returns a decoder for gzip, deflate, br, zstd, chunked or
* identity. List several comma-separated, in the order they were applied.
* @example
* ```javascript
* const compress = require('nuclei/compress');
* const d = compress.NewDecoder('br');
* // Content-Encoding: gzip sent with Transfer-Encoding: chunked
* const c = compress.NewDecoder('gzip, chunked');
* ```
*/
export function NewDecoder(encoding: string): Decoder | null {
return null;
}
/**
* Decoder decodes a compressed stream written to it in chunks. Each call
* returns up to about 1 MB; call Read until it returns nothing before the
* next Write.
* @example
* ```javascript
* const compress = require('nuclei/compress');
* const hash = require('nuclei/hash');
* const d = compress.NewDecoder('gzip');
* const h = hash.New('sha256');
* for (const chunk of chunks) {
* for (let out = d.Write(chunk); out.length > 0; out = d.Read()) h.Write(out);
* }
* for (let out = d.Close(); out.length > 0; out = d.Read()) h.Write(out);
* log(h.Sum());
* ```
*/
export class Decoder {
// Constructor of Decoder
constructor() {}
/**
* Write decodes the next chunk and returns up to about 1 MB of output.
* @example
* ```javascript
* const compress = require('nuclei/compress');
* const d = compress.NewDecoder('gzip');
* const out = d.Write(chunk);
* ```
*/
public Write(chunk: Uint8Array): Uint8Array | null {
return null;
}
/**
* Read returns more decoded bytes, or none once the decoder needs input.
* @example
* ```javascript
* const compress = require('nuclei/compress');
* const d = compress.NewDecoder('gzip');
* for (let out = d.Write(chunk); out.length > 0; out = d.Read()) log(out.length);
* ```
*/
public Read(): Uint8Array | null {
return null;
}
/**
* Close ends the input and returns the last decoded bytes. A truncated
* stream errors once the bytes before the cut have been returned.
* @example
* ```javascript
* const compress = require('nuclei/compress');
* const d = compress.NewDecoder('gzip');
* d.Write(chunk);
* for (let out = d.Close(); out.length > 0; out = d.Read()) log(out.length);
* ```
*/
public Close(): Uint8Array | null {
return null;
}
/**
* Finished reports whether a stream that marks its own end, such as a
* chunked body, has ended and all its output was read.
* @example
* ```javascript
* const compress = require('nuclei/compress');
* const d = compress.NewDecoder('gzip, chunked');
* while (!d.Finished()) for (let out = d.Write(conn.Recv(65536)); out.length > 0; out = d.Read()) log(out.length);
* ```
*/
public Finished(): boolean {
return false;
}
}
-77
View File
@@ -1,77 +0,0 @@
/**
* New returns a running hash for md5, sha1, sha256, sha512 or mmh3. Sum
* matches the DSL function of the same name.
* @example
* ```javascript
* const hash = require('nuclei/hash');
* const h = hash.New('sha256');
* ```
*/
export function New(algorithm: string): Hash | null {
return null;
}
/**
* Hash is a running hash.
* @example
* ```javascript
* const hash = require('nuclei/hash');
* const h = hash.New('sha256');
* h.WriteString('hello');
* log(h.Sum());
* ```
*/
export class Hash {
// Constructor of Hash
constructor() {}
/**
* Write adds data to the hash.
* @example
* ```javascript
* const hash = require('nuclei/hash');
* const h = hash.New('sha256');
* h.Write([104, 105]);
* ```
*/
public Write(data: Uint8Array): void {
return;
}
/**
* WriteString adds the string data to the hash.
* @example
* ```javascript
* const hash = require('nuclei/hash');
* const h = hash.New('sha256');
* h.WriteString('hi');
* ```
*/
public WriteString(data: string): void {
return;
}
/**
* Sum returns the hash of the data written so far, formatted as the DSL does.
* @example
* ```javascript
* const hash = require('nuclei/hash');
* const h = hash.New('mmh3');
* h.WriteString('hello');
* log(h.Sum());
* ```
*/
public Sum(): string | null {
return null;
}
}
-2
View File
@@ -1,11 +1,9 @@
export * as bytes from './bytes';
export * as compress from './compress';
export * as dcom from './dcom';
export * as dcerpc from './dcerpc';
export * as fs from './fs';
export * as goconsole from './goconsole';
export * as grpc from './grpc';
export * as hash from './hash';
export * as http from './http';
export * as ikev2 from './ikev2';
export * as kerberos from './kerberos';
-303
View File
@@ -1,303 +0,0 @@
// Package compress exposes streaming decompression to nuclei JavaScript templates.
package compress
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http/httputil"
"slices"
"strings"
"github.com/dsnet/compress/brotli"
"github.com/klauspost/compress/gzip"
"github.com/klauspost/compress/zlib"
"github.com/klauspost/compress/zstd"
)
// maxDecodedChunk caps the decoded bytes one call hands back.
const maxDecodedChunk = 1 << 20
type pauseReason int
const (
needInput pauseReason = iota
outputFull
finished
)
type (
// Decoder decodes a compressed stream written to it in chunks. Each call
// returns up to about 1 MB; call Read until it returns nothing before the
// next Write.
// @example
// ```javascript
// const compress = require('nuclei/compress');
// const hash = require('nuclei/hash');
// const d = compress.NewDecoder('gzip');
// const h = hash.New('sha256');
// for (const chunk of chunks) {
// for (let out = d.Write(chunk); out.length > 0; out = d.Read()) h.Write(out);
// }
// for (let out = d.Close(); out.length > 0; out = d.Read()) h.Write(out);
// log(h.Sum());
// ```
Decoder struct {
ctx context.Context
// The decode goroutine and the template's calls take turns over these
// channels, so no field is accessed by both at once.
resume chan []byte
paused chan pauseReason
done chan struct{}
out []byte
state pauseReason
inputEnded bool
pending []byte
eof bool
err error
}
)
// NewDecoder returns a decoder for gzip, deflate, br, zstd, chunked or
// identity. List several comma-separated, in the order they were applied.
// @example
// ```javascript
// const compress = require('nuclei/compress');
// const d = compress.NewDecoder('br');
// // Content-Encoding: gzip sent with Transfer-Encoding: chunked
// const c = compress.NewDecoder('gzip, chunked');
// ```
func NewDecoder(ctx context.Context, encoding string) (*Decoder, error) {
var opens []func(io.Reader) (io.Reader, func(), error)
for name := range strings.SplitSeq(encoding, ",") {
switch name = strings.ToLower(strings.TrimSpace(name)); name {
case "gzip":
opens = append(opens, func(r io.Reader) (io.Reader, func(), error) {
zr, err := gzip.NewReader(r)
return zr, func() {}, err
})
case "deflate":
opens = append(opens, func(r io.Reader) (io.Reader, func(), error) {
zr, err := zlib.NewReader(r)
return zr, func() {}, err
})
case "br":
opens = append(opens, func(r io.Reader) (io.Reader, func(), error) {
br, err := brotli.NewReader(r, nil)
return br, func() {}, err
})
case "zstd":
opens = append(opens, func(r io.Reader) (io.Reader, func(), error) {
// One goroutine so no background workers outlive the stream.
zr, err := zstd.NewReader(r, zstd.WithDecoderConcurrency(1))
if err != nil {
return nil, nil, err
}
return zr, zr.Close, nil
})
case "chunked":
opens = append(opens, func(r io.Reader) (io.Reader, func(), error) {
return httputil.NewChunkedReader(r), func() {}, nil
})
case "", "identity":
default:
return nil, fmt.Errorf("unsupported encoding %q, expected gzip, deflate, br, zstd, chunked or identity", name)
}
}
slices.Reverse(opens)
open := func(r io.Reader) (io.Reader, func(), error) {
var closers []func()
closeAll := func() {
for _, c := range slices.Backward(closers) {
c()
}
}
for _, o := range opens {
next, c, err := o(r)
if err != nil {
closeAll()
return nil, nil, err
}
r = next
closers = append(closers, c)
}
return r, closeAll, nil
}
d := &Decoder{
ctx: ctx,
resume: make(chan []byte),
paused: make(chan pauseReason),
done: make(chan struct{}),
}
go d.run(open)
d.wait()
return d, nil
}
// Write decodes the next chunk and returns up to about 1 MB of output.
// @example
// ```javascript
// const compress = require('nuclei/compress');
// const d = compress.NewDecoder('gzip');
// const out = d.Write(chunk);
// ```
func (d *Decoder) Write(chunk []byte) ([]byte, error) {
if d.done == nil {
return nil, errNotCreatedByNewDecoder
}
if d.inputEnded {
return nil, errors.New("decoder is closed")
}
if d.state == outputFull {
return nil, errors.New("decoded bytes are waiting: call Read until it returns no bytes before writing more")
}
if d.state == needInput && len(chunk) > 0 {
// The decode goroutine may still hold the chunk after Write returns.
d.resumeWith(bytes.Clone(chunk))
}
return d.result()
}
// Read returns more decoded bytes, or none once the decoder needs input.
// @example
// ```javascript
// const compress = require('nuclei/compress');
// const d = compress.NewDecoder('gzip');
// for (let out = d.Write(chunk); out.length > 0; out = d.Read()) log(out.length);
// ```
func (d *Decoder) Read() ([]byte, error) {
if d.done == nil {
return nil, errNotCreatedByNewDecoder
}
d.advance()
return d.result()
}
// Close ends the input and returns the last decoded bytes. A truncated
// stream errors once the bytes before the cut have been returned.
// @example
// ```javascript
// const compress = require('nuclei/compress');
// const d = compress.NewDecoder('gzip');
// d.Write(chunk);
// for (let out = d.Close(); out.length > 0; out = d.Read()) log(out.length);
// ```
func (d *Decoder) Close() ([]byte, error) {
if d.done == nil {
return nil, errNotCreatedByNewDecoder
}
d.inputEnded = true
d.advance()
return d.result()
}
// Finished reports whether a stream that marks its own end, such as a
// chunked body, has ended and all its output was read.
// @example
// ```javascript
// const compress = require('nuclei/compress');
// const d = compress.NewDecoder('gzip, chunked');
// while (!d.Finished()) for (let out = d.Write(conn.Recv(65536)); out.length > 0; out = d.Read()) log(out.length);
// ```
func (d *Decoder) Finished() bool {
return d.state == finished && len(d.out) == 0
}
var errNotCreatedByNewDecoder = errors.New("decoder was not created with compress.NewDecoder")
func (d *Decoder) advance() {
for len(d.out) == 0 && (d.state == outputFull || d.state == needInput && d.inputEnded) {
d.resumeWith(nil)
}
}
func (d *Decoder) result() ([]byte, error) {
out := d.out
d.out = nil
if len(out) == 0 && d.state == finished {
return nil, d.err
}
return out, nil
}
func (d *Decoder) resumeWith(chunk []byte) {
select {
case d.resume <- chunk:
d.wait()
case <-d.done:
d.state = finished
}
}
func (d *Decoder) wait() {
select {
case d.state = <-d.paused:
case <-d.done:
d.state = finished
}
}
func (d *Decoder) run(open func(io.Reader) (io.Reader, func(), error)) {
defer close(d.done)
r, closeReader, err := open(decoderInput{d})
if err != nil {
d.err = err
return
}
defer closeReader()
_, err = io.Copy(decoderOutput{d}, r)
d.err = err
}
func (d *Decoder) pause(reason pauseReason) ([]byte, error) {
select {
case d.paused <- reason:
case <-d.ctx.Done():
return nil, context.Cause(d.ctx)
}
select {
case chunk := <-d.resume:
return chunk, nil
case <-d.ctx.Done():
return nil, context.Cause(d.ctx)
}
}
type decoderInput struct{ d *Decoder }
func (in decoderInput) Read(p []byte) (int, error) {
d := in.d
for len(d.pending) == 0 {
if d.eof {
return 0, io.EOF
}
chunk, err := d.pause(needInput)
if err != nil {
return 0, err
}
if chunk == nil {
d.eof = true
}
d.pending = chunk
}
n := copy(p, d.pending)
d.pending = d.pending[n:]
return n, nil
}
type decoderOutput struct{ d *Decoder }
func (out decoderOutput) Write(p []byte) (int, error) {
d := out.d
d.out = append(d.out, p...)
if len(d.out) >= maxDecodedChunk {
if _, err := d.pause(outputFull); err != nil {
return 0, err
}
}
return len(p), nil
}
-192
View File
@@ -1,192 +0,0 @@
package compress
import (
"bytes"
"context"
"io"
"math/rand"
"net/http/httputil"
"testing"
"time"
"github.com/andybalholm/brotli"
"github.com/klauspost/compress/gzip"
"github.com/klauspost/compress/zlib"
"github.com/klauspost/compress/zstd"
"github.com/stretchr/testify/require"
)
func compressed(t *testing.T, encoding string, data []byte) []byte {
t.Helper()
var buf bytes.Buffer
var w io.WriteCloser
switch encoding {
case "gzip":
w = gzip.NewWriter(&buf)
case "deflate":
w = zlib.NewWriter(&buf)
case "br":
w = brotli.NewWriter(&buf)
case "zstd":
zw, err := zstd.NewWriter(&buf)
require.NoError(t, err)
w = zw
case "identity":
return data
}
_, err := w.Write(data)
require.NoError(t, err)
require.NoError(t, w.Close())
return buf.Bytes()
}
func decodeInChunks(t *testing.T, d *Decoder, input []byte) ([]byte, error) {
t.Helper()
rng := rand.New(rand.NewSource(1))
var decoded []byte
collect := func(out []byte, err error) bool {
require.LessOrEqual(t, len(out), maxDecodedChunk+64<<10)
decoded = append(decoded, out...)
return err == nil && len(out) > 0
}
drain := func(out []byte, err error) error {
for collect(out, err) {
out, err = d.Read()
}
return err
}
for len(input) > 0 {
n := min(len(input), 1+rng.Intn(70000))
out, err := d.Write(input[:n])
if err := drain(out, err); err != nil {
return decoded, err
}
input = input[n:]
}
out, err := d.Close()
return decoded, drain(out, err)
}
func TestDecoderMatchesWholeBodyDecode(t *testing.T) {
data := bytes.Repeat([]byte("nuclei streaming decoder "), 400000)
for _, encoding := range []string{"gzip", "deflate", "br", "zstd", "identity"} {
t.Run(encoding, func(t *testing.T) {
d, err := NewDecoder(t.Context(), encoding)
require.NoError(t, err)
decoded, err := decodeInChunks(t, d, compressed(t, encoding, data))
require.NoError(t, err)
require.Equal(t, data, decoded)
})
}
}
func chunked(t *testing.T, data []byte) []byte {
t.Helper()
var buf bytes.Buffer
w := httputil.NewChunkedWriter(&buf)
for len(data) > 0 {
n := min(len(data), 8000)
_, err := w.Write(data[:n])
require.NoError(t, err)
data = data[n:]
}
require.NoError(t, w.Close())
buf.WriteString("\r\n")
return buf.Bytes()
}
func TestDecoderDecodesChunkedTransfer(t *testing.T) {
data := bytes.Repeat([]byte("nuclei chunked decoder "), 400000)
for _, encoding := range []string{"gzip", "deflate", "br", "zstd", "identity"} {
t.Run(encoding, func(t *testing.T) {
d, err := NewDecoder(t.Context(), encoding+", chunked")
require.NoError(t, err)
decoded, err := decodeInChunks(t, d, chunked(t, compressed(t, encoding, data)))
require.NoError(t, err)
require.Equal(t, data, decoded)
})
}
}
func TestDecoderFinishesAtLastChunk(t *testing.T) {
data := bytes.Repeat([]byte("finished "), 1000)
d, err := NewDecoder(t.Context(), "gzip, chunked")
require.NoError(t, err)
input := chunked(t, compressed(t, "gzip", data))
half := len(input) / 2
_, err = d.Write(input[:half])
require.NoError(t, err)
require.False(t, d.Finished())
out, err := d.Write(input[half:])
require.NoError(t, err)
var decoded []byte
for ; len(out) > 0; out, err = d.Read() {
decoded = append(decoded, out...)
}
require.NoError(t, err)
require.Equal(t, data, decoded)
require.True(t, d.Finished(), "decoder must finish without Close once the last chunk arrives")
}
func TestDecoderAppliesCodingsInReverse(t *testing.T) {
data := bytes.Repeat([]byte("two codings "), 100000)
input := compressed(t, "br", compressed(t, "gzip", data))
d, err := NewDecoder(t.Context(), "gzip, br")
require.NoError(t, err)
decoded, err := decodeInChunks(t, d, input)
require.NoError(t, err)
require.Equal(t, data, decoded)
}
func TestDecoderReportsTruncatedStream(t *testing.T) {
data := bytes.Repeat([]byte("truncated "), 200000)
for _, encoding := range []string{"gzip", "deflate", "br", "zstd", "identity, chunked"} {
t.Run(encoding, func(t *testing.T) {
var input []byte
if encoding == "identity, chunked" {
input = chunked(t, data)
} else {
input = compressed(t, encoding, data)
}
d, err := NewDecoder(t.Context(), encoding)
require.NoError(t, err)
decoded, err := decodeInChunks(t, d, input[:len(input)/2])
require.Error(t, err)
require.True(t, bytes.HasPrefix(data, decoded), "bytes before the cut must still be handed back")
})
}
}
func TestDecoderStopsWhenContextEnds(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
d, err := NewDecoder(ctx, "gzip")
require.NoError(t, err)
_, err = d.Write(compressed(t, "gzip", []byte("abandoned"))[:5])
require.NoError(t, err)
cancel()
select {
case <-d.done:
case <-time.After(time.Second):
t.Fatal("decode goroutine outlived its context")
}
}
func TestNewDecoderRejectsUnknownEncoding(t *testing.T) {
_, err := NewDecoder(t.Context(), "compress")
require.Error(t, err)
_, err = NewDecoder(t.Context(), "gzip, compress")
require.Error(t, err)
}
func TestDecoderNotCreatedByNewDecoderErrors(t *testing.T) {
var d Decoder
_, err := d.Write([]byte("x"))
require.ErrorIs(t, err, errNotCreatedByNewDecoder)
_, err = d.Read()
require.ErrorIs(t, err, errNotCreatedByNewDecoder)
_, err = d.Close()
require.ErrorIs(t, err, errNotCreatedByNewDecoder)
}
-100
View File
@@ -1,100 +0,0 @@
// Package hash exposes incremental hashes to nuclei JavaScript templates.
package hash
import (
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"errors"
"fmt"
"hash"
"strings"
"github.com/spaolacci/murmur3"
)
type (
// Hash is a running hash.
// @example
// ```javascript
// const hash = require('nuclei/hash');
// const h = hash.New('sha256');
// h.WriteString('hello');
// log(h.Sum());
// ```
Hash struct {
h hash.Hash
mmh3 bool
}
)
// New returns a running hash for md5, sha1, sha256, sha512 or mmh3. Sum
// matches the DSL function of the same name.
// @example
// ```javascript
// const hash = require('nuclei/hash');
// const h = hash.New('sha256');
// ```
func New(algorithm string) (*Hash, error) {
switch strings.ToLower(algorithm) {
case "md5":
return &Hash{h: md5.New()}, nil
case "sha1":
return &Hash{h: sha1.New()}, nil
case "sha256":
return &Hash{h: sha256.New()}, nil
case "sha512":
return &Hash{h: sha512.New()}, nil
case "mmh3":
return &Hash{h: murmur3.New32WithSeed(0), mmh3: true}, nil
}
return nil, fmt.Errorf("unsupported hash algorithm %q, expected md5, sha1, sha256, sha512 or mmh3", algorithm)
}
// Write adds data to the hash.
// @example
// ```javascript
// const hash = require('nuclei/hash');
// const h = hash.New('sha256');
// h.Write([104, 105]);
// ```
func (h *Hash) Write(data []byte) error {
if h.h == nil {
return errNotCreatedByNew
}
_, _ = h.h.Write(data)
return nil
}
// WriteString adds the string data to the hash.
// @example
// ```javascript
// const hash = require('nuclei/hash');
// const h = hash.New('sha256');
// h.WriteString('hi');
// ```
func (h *Hash) WriteString(data string) error {
return h.Write([]byte(data))
}
// Sum returns the hash of the data written so far, formatted as the DSL does.
// @example
// ```javascript
// const hash = require('nuclei/hash');
// const h = hash.New('mmh3');
// h.WriteString('hello');
// log(h.Sum());
// ```
func (h *Hash) Sum() (string, error) {
if h.h == nil {
return "", errNotCreatedByNew
}
if h.mmh3 {
return fmt.Sprintf("%d", int32(h.h.(hash.Hash32).Sum32())), nil
}
return hex.EncodeToString(h.h.Sum(nil)), nil
}
var errNotCreatedByNew = errors.New("hash was not created with hash.New")
-43
View File
@@ -1,43 +0,0 @@
package hash
import (
"testing"
"github.com/projectdiscovery/dsl"
"github.com/stretchr/testify/require"
)
func TestSumMatchesDSLAcrossChunks(t *testing.T) {
data := make([]byte, 3<<20+7)
for i := range data {
data[i] = byte(i * 31)
}
for _, algorithm := range []string{"md5", "sha1", "sha256", "sha512", "mmh3"} {
t.Run(algorithm, func(t *testing.T) {
h, err := New(algorithm)
require.NoError(t, err)
for chunk := data; len(chunk) > 0; {
n := min(len(chunk), 65521)
require.NoError(t, h.Write(chunk[:n]))
chunk = chunk[n:]
}
want, err := dsl.EvalExpr(algorithm+"(body)", map[string]interface{}{"body": string(data)})
require.NoError(t, err)
sum, err := h.Sum()
require.NoError(t, err)
require.Equal(t, want, sum)
})
}
}
func TestNewRejectsUnknownAlgorithm(t *testing.T) {
_, err := New("crc32")
require.Error(t, err)
}
func TestHashNotCreatedByNewErrors(t *testing.T) {
var h Hash
require.ErrorIs(t, h.WriteString("x"), errNotCreatedByNew)
_, err := h.Sum()
require.ErrorIs(t, err, errNotCreatedByNew)
}
@@ -2,11 +2,7 @@ package mysql
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"testing"
@@ -173,84 +169,6 @@ func TestFingerprintMySQLDeniedByNetworkPolicy(t *testing.T) {
require.Contains(t, err.Error(), "127.0.0.1")
}
func TestConnectWithDSNDeniedByNetworkPolicy(t *testing.T) {
t.Parallel()
executionID := "mysql-dsn-deny-" + strings.NewReplacer("/", "-", " ", "-").Replace(t.Name())
require.NoError(t, protocolstate.Init(&types.Options{
ExecutionId: executionID,
RestrictLocalNetworkAccess: true,
}))
t.Cleanup(func() { protocolstate.Close(executionID) })
ctx := context.WithValue(context.Background(), "executionId", executionID) //nolint:staticcheck
var accepted atomic.Int32
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
accepted.Add(1)
_ = conn.Close()
}
}()
port := ln.Addr().(*net.TCPAddr).Port
dsn := fmt.Sprintf("vuser:vpass@tcp(127.0.0.1:%d)/vdb", port)
ok, err := (&MySQLClient{}).ConnectWithDSN(ctx, dsn)
require.Error(t, err)
require.False(t, ok)
require.Contains(t, err.Error(), "127.0.0.1")
require.Zero(t, accepted.Load())
}
func TestConnectWithDSNUnixDeniedByNetworkPolicy(t *testing.T) {
t.Parallel()
executionID := "mysql-dsn-unix-deny-" + strings.NewReplacer("/", "-", " ", "-").Replace(t.Name())
require.NoError(t, protocolstate.Init(&types.Options{
ExecutionId: executionID,
RestrictLocalNetworkAccess: true,
}))
t.Cleanup(func() { protocolstate.Close(executionID) })
ctx := context.WithValue(context.Background(), "executionId", executionID) //nolint:staticcheck
// t.TempDir can exceed the unix socket path limit on macOS, so use a short dir.
dir, err := os.MkdirTemp("", "nuclei-mysql")
require.NoError(t, err)
t.Cleanup(func() { _ = os.RemoveAll(dir) })
sock := filepath.Join(dir, "lna.sock")
ln, err := net.Listen("unix", sock)
if err != nil && runtime.GOOS == "windows" {
t.Skipf("unix sockets not available: %v", err)
}
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
var accepted atomic.Int32
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
accepted.Add(1)
_ = conn.Close()
}
}()
dsn := fmt.Sprintf("vuser:vpass@unix(%s)/vdb", sock)
ok, err := (&MySQLClient{}).ConnectWithDSN(ctx, dsn)
require.Error(t, err)
require.False(t, ok)
require.Contains(t, err.Error(), "127.0.0.1")
require.Zero(t, accepted.Load())
}
func TestFingerprintMySQLVersionNeverEmptyOnHandshakeFixtures(t *testing.T) {
t.Parallel()
+7 -40
View File
@@ -76,57 +76,24 @@ func BuildDSN(opts MySQLOptions) (string, error) {
return dsn.String(), nil
}
// sandboxDSN enforces local file and network policy on a MySQL DSN.
// allowAllFiles is only honored when -lfa is enabled. tcp, tcp4 and tcp6
// DSNs are rewritten to nucleitcp so the driver uses the fastdialer
// registered in protocolstate. Unix sockets are treated as loopback for -lna.
// Other networks are rejected.
func sandboxDSN(executionId, dsn string) (string, error) {
// 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
}
opts := &types.Options{ExecutionId: executionId}
if cfg.AllowAllFiles && !protocolstate.IsLfaAllowed(opts) {
if cfg.AllowAllFiles && !lfaAllowed {
cfg.AllowAllFiles = false
}
netName := cfg.Net
if netName == "" {
netName = "tcp"
}
switch netName {
case "unix":
// IP-based policy never sees a socket path; gate on loopback like ldapi.
const unixPolicyHost = "127.0.0.1"
if !protocolstate.IsHostAllowed(executionId, unixPolicyHost) {
return "", protocolstate.ErrHostDenied.Msgf(unixPolicyHost)
}
case "tcp", "tcp4", "tcp6", "nucleitcp":
// Only nucleitcp has a registered dialer; the others would use the
// driver's plain net.Dialer and skip the dial-time policy check.
cfg.Net = "nucleitcp"
host := dsnPolicyHost(cfg.Addr)
if !protocolstate.IsHostAllowed(executionId, host) {
return "", protocolstate.ErrHostDenied.Msgf(host)
}
default:
return "", fmt.Errorf("unsupported mysql network %q", netName)
}
return cfg.FormatDSN(), nil
}
func dsnPolicyHost(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err == nil && host != "" {
return host
}
return addr
}
// openDB opens a sandboxed MySQL connection from dsn.
func openDB(executionId, dsn string) (*sql.DB, error) {
dsn, err := sandboxDSN(executionId, dsn)
dsn, err := sandboxDSN(dsn, protocolstate.IsLfaAllowed(&types.Options{ExecutionId: executionId}))
if err != nil {
return nil, err
}
+4 -86
View File
@@ -5,14 +5,11 @@ import (
"github.com/go-sql-driver/mysql"
"github.com/stretchr/testify/require"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
)
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")
got, err := sandboxDSN("root:x@nucleitcp(127.0.0.1:3306)/?allowAllFiles=true", false)
require.NoError(t, err)
cfg, err := mysql.ParseDSN(got)
@@ -21,14 +18,7 @@ func TestSandboxDSN(t *testing.T) {
})
t.Run("keeps allowAllFiles when lfa enabled", func(t *testing.T) {
executionID := "mysql-lfa-" + t.Name()
require.NoError(t, protocolstate.Init(&types.Options{
ExecutionId: executionID,
AllowLocalFileAccess: true,
}))
t.Cleanup(func() { protocolstate.Close(executionID) })
got, err := sandboxDSN(executionID, "root:x@nucleitcp(127.0.0.1:3306)/?allowAllFiles=true")
got, err := sandboxDSN("root:x@nucleitcp(127.0.0.1:3306)/?allowAllFiles=true", true)
require.NoError(t, err)
cfg, err := mysql.ParseDSN(got)
@@ -37,7 +27,7 @@ func TestSandboxDSN(t *testing.T) {
})
t.Run("leaves dsn without allowAllFiles untouched", func(t *testing.T) {
got, err := sandboxDSN("", "root:x@nucleitcp(127.0.0.1:3306)/")
got, err := sandboxDSN("root:x@nucleitcp(127.0.0.1:3306)/", false)
require.NoError(t, err)
cfg, err := mysql.ParseDSN(got)
@@ -45,80 +35,8 @@ func TestSandboxDSN(t *testing.T) {
require.False(t, cfg.AllowAllFiles)
})
t.Run("rewrites tcp to nucleitcp", func(t *testing.T) {
got, err := sandboxDSN("", "root:x@tcp(acme.com:3306)/")
require.NoError(t, err)
cfg, err := mysql.ParseDSN(got)
require.NoError(t, err)
require.Equal(t, "nucleitcp", cfg.Net)
require.Equal(t, "acme.com:3306", cfg.Addr)
})
t.Run("rewrites tcp4 and tcp6 to nucleitcp", func(t *testing.T) {
for _, netName := range []string{"tcp4", "tcp6"} {
got, err := sandboxDSN("", "root:x@"+netName+"(acme.com:3306)/")
require.NoError(t, err)
cfg, err := mysql.ParseDSN(got)
require.NoError(t, err)
require.Equal(t, "nucleitcp", cfg.Net)
require.Equal(t, "acme.com:3306", cfg.Addr)
}
})
t.Run("rejects unsupported network", func(t *testing.T) {
_, err := sandboxDSN("", "root:x@udp(acme.com:3306)/")
require.Error(t, err)
require.Contains(t, err.Error(), "udp")
})
t.Run("errors on invalid dsn", func(t *testing.T) {
_, err := sandboxDSN("", "::not-a-dsn::")
_, err := sandboxDSN("::not-a-dsn::", false)
require.Error(t, err)
})
t.Run("denies loopback tcp when lna enabled", func(t *testing.T) {
executionID := "mysql-lna-tcp-" + t.Name()
require.NoError(t, protocolstate.Init(&types.Options{
ExecutionId: executionID,
RestrictLocalNetworkAccess: true,
}))
t.Cleanup(func() { protocolstate.Close(executionID) })
_, err := sandboxDSN(executionID, "vuser:vpass@tcp(127.0.0.1:3306)/vdb")
require.Error(t, err)
require.Contains(t, err.Error(), "127.0.0.1")
})
t.Run("denies loopback variants when lna enabled", func(t *testing.T) {
executionID := "mysql-lna-variants-" + t.Name()
require.NoError(t, protocolstate.Init(&types.Options{
ExecutionId: executionID,
RestrictLocalNetworkAccess: true,
}))
t.Cleanup(func() { protocolstate.Close(executionID) })
for _, dsn := range []string{
"vuser:vpass@tcp4(127.0.0.1:3306)/vdb",
"vuser:vpass@tcp6([::1]:3306)/vdb",
"vuser:vpass@tcp([::ffff:127.0.0.1]:3306)/vdb",
} {
_, err := sandboxDSN(executionID, dsn)
require.Error(t, err, dsn)
}
})
t.Run("denies unix socket when lna enabled", func(t *testing.T) {
executionID := "mysql-lna-unix-" + t.Name()
require.NoError(t, protocolstate.Init(&types.Options{
ExecutionId: executionID,
RestrictLocalNetworkAccess: true,
}))
t.Cleanup(func() { protocolstate.Close(executionID) })
_, err := sandboxDSN(executionID, "vuser:vpass@unix(/tmp/nuclei-mysql-lna.sock)/vdb")
require.Error(t, err)
require.Contains(t, err.Error(), "127.0.0.1")
})
}
+1
View File
@@ -3,6 +3,7 @@ package redis
import (
"context"
"errors"
"fmt"
+117 -113
View File
@@ -3,12 +3,13 @@ package redis
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"strconv"
"time"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/redis/go-redis/v9"
@@ -268,10 +269,9 @@ var errReadLimit = fmt.Errorf("redis: reply exceeds %d bytes", maxReadBytes)
// maxReadBytes.
func newClient(addr, password string) *redis.Client {
opts := &redis.Options{
Addr: addr,
Password: password,
DB: 0,
DialerRetries: dialAttempts,
Addr: addr,
Password: password,
DB: 0,
}
dial := redis.NewDialer(opts)
opts.Dialer = func(ctx context.Context, network, addr string) (net.Conn, error) {
@@ -285,134 +285,138 @@ func newClient(addr, password string) *redis.Client {
return redis.NewClient(opts)
}
// maxHeaderLen fits a type byte, any int64, and "\r". Redis never sends a longer length header.
const maxHeaderLen = 32
// limitedConn fails a read once remaining is spent, and ends a read before the "\n" of a RESP
// length header whose declared size is over the cap, so go-redis never gets the line to allocate
// from.
//
// It follows the reply's framing as the bytes pass: a bulk payload is skipped by its declared
// length, so only header lines are checked. A payload line such as "*abc" is data.
// limitedConn fails a read once remaining is spent, and withholds a RESP length line whose
// declared size is over the cap so go-redis never allocates it.
type limitedConn struct {
net.Conn
remaining int64
// payload is how many bytes of the current bulk payload, "\r\n" included, are still to come.
payload int64
// header is the start of the current header line, without its "\n".
header []byte
overlong bool
err error
pending []byte
partial []byte
err error
scratch [32 << 10]byte
}
func (c *limitedConn) Read(b []byte) (int, error) {
if c.err != nil {
return 0, c.err
}
if c.remaining <= 0 {
return 0, errReadLimit
}
if int64(len(b)) > c.remaining {
b = b[:c.remaining]
}
n, err := c.Conn.Read(b)
c.remaining -= int64(n)
// Errors from the connection, deadlines included, are returned as they come and not kept: a
// read deadline belongs to one call. go-redis peeks for push notifications with a few
// microseconds of deadline, and keeping that timeout would fail the command that follows.
kept, scanErr := c.scan(b[:n])
if scanErr == nil {
return n, err
}
c.err = scanErr
if kept == 0 {
return 0, scanErr
}
return kept, nil
}
// scan follows the RESP framing of p and returns how many of its bytes may be returned.
func (c *limitedConn) scan(p []byte) (int, error) {
for i := 0; i < len(p); {
if c.payload > 0 {
skip := min(c.payload, int64(len(p)-i))
c.payload -= skip
i += int(skip)
continue
}
end := bytes.IndexByte(p[i:], '\n')
if end < 0 {
c.keepHeader(p[i:])
return len(p), nil
}
c.keepHeader(p[i : i+end])
payload, err := declaredPayload(c.header, c.overlong)
c.header, c.overlong = c.header[:0], false
if err != nil {
return i + end, err
}
c.payload = payload
i += end + 1
}
return len(p), nil
}
func (c *limitedConn) keepHeader(p []byte) {
if room := maxHeaderLen - len(c.header); len(p) > room {
p = p[:room]
c.overlong = true
}
c.header = append(c.header, p...)
}
// declaredPayload returns how many payload bytes, "\r\n" included, follow a RESP header line
// given without its "\n". It returns errReadLimit when go-redis would allocate more than the cap
// from the line: the declared byte length for bulk strings, one interface per declared element
// for arrays, sets, maps, attributes, and pushes.
func declaredPayload(line []byte, overlong bool) (int64, error) {
if len(line) == 0 {
if len(b) == 0 {
return 0, nil
}
if len(c.pending) == 0 {
if err := c.fill(); err != nil {
return 0, err
}
}
n := copy(b, c.pending)
c.pending = c.pending[n:]
return n, nil
}
func (c *limitedConn) fill() error {
if len(c.pending) > 0 {
return nil
}
if c.err != nil {
return c.err
}
for len(c.pending) == 0 {
if c.remaining <= 0 {
c.err = errReadLimit
return c.err
}
want := int64(len(c.scratch))
if want > c.remaining {
want = c.remaining
}
n, err := c.Conn.Read(c.scratch[:want])
if n > 0 {
c.remaining -= int64(n)
if aerr := c.accept(c.scratch[:n]); aerr != nil {
c.err = aerr
if len(c.pending) > 0 {
return nil
}
return aerr
}
}
if err != nil {
// A read deadline belongs to one call. go-redis peeks for push notifications
// with a few microseconds of deadline, and on Windows that peek always runs.
// Keeping the timeout fails the command that follows.
if timeoutErr(err) {
if len(c.pending) > 0 {
return nil
}
return err
}
c.err = err
if len(c.pending) > 0 {
return nil
}
return err
}
if n == 0 {
c.err = io.ErrUnexpectedEOF
return c.err
}
}
return nil
}
func (c *limitedConn) accept(p []byte) error {
c.partial = append(c.partial, p...)
for {
i := bytes.IndexByte(c.partial, '\n')
if i < 0 {
if int64(len(c.partial)) > maxReadBytes {
c.partial = nil
return errReadLimit
}
return nil
}
line := c.partial[:i+1]
if lengthTooLarge(line) {
c.partial = nil
return errReadLimit
}
copied := make([]byte, len(line))
copy(copied, line)
c.pending = append(c.pending, copied...)
rest := make([]byte, len(c.partial)-(i+1))
copy(rest, c.partial[i+1:])
c.partial = rest
}
}
// lengthTooLarge reports whether line is a RESP length header go-redis would allocate from.
// Bulk strings allocate the declared byte length. Arrays, sets, maps, attributes, and pushes
// allocate one interface per declared element.
func lengthTooLarge(line []byte) bool {
if len(line) < 3 {
return false
}
var limit int64
bulk := false
switch line[0] {
case '$', '=', '!':
limit, bulk = maxReadBytes, true
limit = maxReadBytes
case '*', '~', '>', '%', '|':
limit = maxAggregate
default:
return 0, nil
return false
}
if overlong {
return 0, errReadLimit
body := line[1 : len(line)-1]
if len(body) > 0 && body[len(body)-1] == '\r' {
body = body[:len(body)-1]
}
body := bytes.TrimSuffix(line[1:], []byte("\r"))
if len(body) == 0 || body[0] == '-' {
return 0, nil
return false
}
n, err := strconv.ParseInt(string(body), 10, 64)
if err != nil || n > limit {
return 0, errReadLimit
if err != nil {
return true
}
if !bulk {
return 0, nil
}
return n + 2, nil
return n > limit
}
// dialAttempts replaces go-redis's default of 5. The extra attempts run in a goroutine that
// ignores the caller's context, so they kept dialing a filtered port for ~25s after the
// template had given up on it.
const dialAttempts = 1
func init() {
redis.SetLogger(warningLogger{})
}
// warningLogger hands go-redis's logs to gologger as warnings. Its default logger writes them
// straight to stderr, where a failed dial against the scanned host reads as an error.
type warningLogger struct{}
func (warningLogger) Printf(_ context.Context, format string, v ...interface{}) {
gologger.Warning().Msgf(format, v...)
func timeoutErr(err error) bool {
var netErr net.Error
return errors.As(err, &netErr) && netErr.Timeout()
}
-81
View File
@@ -1,81 +0,0 @@
package redis
import (
"context"
"net"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/gologger/levels"
"github.com/projectdiscovery/gologger/writer"
"github.com/stretchr/testify/require"
)
// go-redis retries a failed dial 4 times, 100ms apart, on each of a command's 4 attempts: a closed
// port took at least 1.6s to fail.
func TestGetServerInfoFailsFastOnAClosedPort(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("windows takes about a second to report a refused localhost dial")
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
addr, ok := listener.Addr().(*net.TCPAddr)
require.True(t, ok)
require.NoError(t, listener.Close())
start := time.Now()
_, err = getServerInfo(context.Background(), "redis-closed-port", "127.0.0.1", addr.Port)
require.Error(t, err)
require.Less(t, time.Since(start), time.Second)
}
// go-redis's default logger wrote each failed dial straight to stderr, past nuclei's logger and
// its level.
func TestGoRedisLogsAreGologgerWarnings(t *testing.T) {
logs := &recordingWriter{}
gologger.DefaultLogger.SetWriter(logs)
gologger.DefaultLogger.SetMaxLevel(levels.LevelWarning)
t.Cleanup(func() {
// gologger's own defaults: it has no getter to save the previous ones.
gologger.DefaultLogger.SetWriter(writer.NewCLI())
gologger.DefaultLogger.SetMaxLevel(levels.LevelInfo)
})
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
addr, ok := listener.Addr().(*net.TCPAddr)
require.True(t, ok)
require.NoError(t, listener.Close())
_, err = getServerInfo(context.Background(), "redis-log-level", "127.0.0.1", addr.Port)
require.Error(t, err)
warnings := logs.at(levels.LevelWarning)
require.NotEmpty(t, warnings)
require.Contains(t, warnings[0], "failed to dial")
}
type recordingWriter struct {
mu sync.Mutex
lines map[levels.Level][]string
}
func (w *recordingWriter) Write(data []byte, level levels.Level) {
w.mu.Lock()
defer w.mu.Unlock()
if w.lines == nil {
w.lines = make(map[levels.Level][]string)
}
w.lines[level] = append(w.lines[level], strings.TrimSpace(string(data)))
}
func (w *recordingWriter) at(level levels.Level) []string {
w.mu.Lock()
defer w.mu.Unlock()
return w.lines[level]
}
+38 -119
View File
@@ -5,7 +5,6 @@ import (
"bytes"
"context"
"fmt"
"io"
"net"
"runtime"
"strconv"
@@ -91,58 +90,37 @@ func TestGetServerInfoAuthBoundsAReplyWithoutNewline(t *testing.T) {
}
func TestGetServerInfoAuthReadsAnInfoReply(t *testing.T) {
tests := []struct {
name string
info string
}{
{name: "info", info: "# Server\r\nredis_version:7.2.4\r\n"},
// A bulk payload's lines are data, even when they start like a RESP length header.
{name: "header-like lines", info: "# Server\r\n*abc\r\n$ 5 dollars\r\n%x\r\n$99999999999\r\n"},
// Each line used to cost a copy of what was left of the read buffer.
{name: "many short lines", info: strings.Repeat("\n", 1<<20)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() {
_ = listener.Close()
})
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() {
_ = listener.Close()
})
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go serveRESP(conn, tt.info)
}
}()
const info = "# Server\r\nredis_version:7.2.4\r\n"
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go serveRESP(conn, info)
}
}()
executionID := "redis-" + strings.ReplaceAll(t.Name(), "/", "-")
require.NoError(t, protocolstate.Init(&types.Options{ExecutionId: executionID}))
t.Cleanup(func() {
protocolstate.Close(executionID)
})
executionID := "redis-" + strings.ReplaceAll(t.Name(), "/", "-")
require.NoError(t, protocolstate.Init(&types.Options{ExecutionId: executionID}))
t.Cleanup(func() {
protocolstate.Close(executionID)
})
addr, ok := listener.Addr().(*net.TCPAddr)
require.True(t, ok)
addr, ok := listener.Addr().(*net.TCPAddr)
require.True(t, ok)
var before runtime.MemStats
runtime.ReadMemStats(&before)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
got, err := getServerInfoAuth(ctx, executionID, "127.0.0.1", addr.Port, "")
var after runtime.MemStats
runtime.ReadMemStats(&after)
require.NoError(t, err)
require.Equal(t, tt.info, got)
growth := int64(after.TotalAlloc - before.TotalAlloc)
require.Less(t, growth, int64(64<<20), "allocated %d MiB reading one reply", growth>>20)
})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
got, err := getServerInfoAuth(ctx, executionID, "127.0.0.1", addr.Port, "")
require.NoError(t, err)
require.Equal(t, info, got)
}
// serveRESP answers HELLO as a Redis without RESP3 does, so the client falls back to RESP2, and
@@ -204,85 +182,26 @@ func readRESPCommand(rd *bufio.Reader) (string, error) {
return command, nil
}
func TestDeclaredPayload(t *testing.T) {
func TestLengthTooLarge(t *testing.T) {
tests := []struct {
line string
overlong bool
payload int64
tooLarge bool
line string
want bool
}{
{line: fmt.Sprintf("$%d\r", maxReadBytes), payload: maxReadBytes + 2},
{line: fmt.Sprintf("$%d\r", maxReadBytes+1), tooLarge: true},
{line: "=10\r", payload: 12},
{line: "$-1\r"},
{line: "+OK\r"},
{line: "+OK\r", overlong: true},
{line: fmt.Sprintf("*%d\r", maxAggregate)},
{line: fmt.Sprintf("*%d\r", maxAggregate+1), tooLarge: true},
{line: "$999999999999999999999\r", tooLarge: true},
{line: "*abc\r", tooLarge: true},
{line: "$000000000000000000000000000000", overlong: true, tooLarge: true},
{line: fmt.Sprintf("$%d\r\n", maxReadBytes), want: false},
{line: fmt.Sprintf("$%d\r\n", maxReadBytes+1), want: true},
{line: "$-1\r\n", want: false},
{line: "+OK\r\n", want: false},
{line: fmt.Sprintf("*%d\r\n", maxAggregate), want: false},
{line: fmt.Sprintf("*%d\r\n", maxAggregate+1), want: true},
{line: "$999999999999999999999\r\n", want: true},
}
for _, tt := range tests {
t.Run(tt.line, func(t *testing.T) {
payload, err := declaredPayload([]byte(tt.line), tt.overlong)
if tt.tooLarge {
require.ErrorIs(t, err, errReadLimit)
return
}
require.NoError(t, err)
require.Equal(t, tt.payload, payload)
require.Equal(t, tt.want, lengthTooLarge([]byte(tt.line)))
})
}
}
// The check that rejects a length header has to land on the "\n" when the header arrives over
// several reads, and payload bytes have to pass whatever they contain.
func TestLimitedConnFollowsFramingAcrossReads(t *testing.T) {
const ok = "*2\r\n$5\r\n*abc\n\r\n$-1\r\n+OK\r\n"
tooLarge := fmt.Sprintf("$%d\r\n", maxReadBytes+1)
tests := []struct {
name string
stream string
want string
wantErr error
}{
{name: "payload passes", stream: ok, want: ok},
{name: "header rejected", stream: ok + tooLarge + "x", want: ok + tooLarge[:len(tooLarge)-1], wantErr: errReadLimit},
}
for _, tt := range tests {
for _, size := range []int{1, 3, 64} {
t.Run(fmt.Sprintf("%s/%d", tt.name, size), func(t *testing.T) {
lc := &limitedConn{Conn: &chunkedConn{data: []byte(tt.stream), size: size}, remaining: maxReadBytes}
got, err := io.ReadAll(lc)
require.Equal(t, tt.want, string(got))
if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr)
return
}
require.NoError(t, err)
})
}
}
}
// chunkedConn returns data at most size bytes per read, then io.EOF.
type chunkedConn struct {
net.Conn
data []byte
size int
}
func (c *chunkedConn) Read(b []byte) (int, error) {
if len(c.data) == 0 {
return 0, io.EOF
}
n := copy(b[:min(len(b), c.size)], c.data)
c.data = c.data[n:]
return n, nil
}
// A short RESP length header used to make go-redis allocate the declared size before reading
// any of the payload, so the byte cap never applied.
func TestGetServerInfoAuthRejectsAHugeDeclaredLength(t *testing.T) {
+6 -54
View File
@@ -16,8 +16,6 @@ import (
type workflowLoader struct {
pathFilter *filter.PathFilter
tagFilter *templates.TagFilter
// the exclusions of tagFilter other than severities, see filters
exclusions *templates.TagFilter
options *protocols.ExecutorOptions
// every workflow step that selects templates by tag searches the whole
@@ -47,29 +45,12 @@ func NewLoader(options *protocols.ExecutorOptions) (model.WorkflowLoader, error)
if err != nil {
return nil, err
}
// IncludeTags is not an inclusion filter: it lifts the exclusion of the
// same tags
exclusions, err := templates.NewTagFilter(&templates.TagFilterConfig{
ExcludeTags: options.Options.ExcludeTags,
IncludeTags: options.Options.IncludeTags,
ExcludeIds: options.Options.ExcludeIds,
ExcludeProtocols: options.Options.ExcludeProtocols,
})
if err != nil {
return nil, err
}
pathFilter := filter.NewPathFilter(&filter.PathFilterConfig{
IncludedTemplates: options.Options.IncludeTemplates,
ExcludedTemplates: options.Options.ExcludedTemplates,
}, options.Catalog)
loader := &workflowLoader{
pathFilter: pathFilter,
tagFilter: tagFilter,
exclusions: exclusions,
options: options,
parsed: make(map[string]*templates.Template),
}
loader := &workflowLoader{pathFilter: pathFilter, tagFilter: tagFilter, options: options, parsed: make(map[string]*templates.Template)}
loader.templatesDirectoryPaths = sync.OnceValue(loader.listTemplatesDirectory)
return loader, nil
}
@@ -104,34 +85,20 @@ func (w *workflowLoader) parseTemplate(path string) *templates.Template {
return template
}
func (w *workflowLoader) GetTemplatePathsByTags(templateTags []string, noValidate bool) []string {
func (w *workflowLoader) GetTemplatePathsByTags(templateTags []string) []string {
var loadedTemplates []string
for _, path := range w.templatesDirectoryPaths() {
// the step's tags select the templates and the filters then decide
// which of them load, as for the templates of a template step
template := w.parseTemplate(path)
if template == nil || !selectedByTags(template, templateTags) {
if template == nil {
continue
}
if matched, _ := templates.MatchLoadFilters(template, path, w.filters(noValidate), nil); matched {
if loaded, _ := templates.MatchLoadFilters(template, path, w.tagFilter, templateTags); loaded {
loadedTemplates = append(loadedTemplates, path)
}
}
return loadedTemplates
}
// selectedByTags reports whether a workflow step that selects templates by
// tags selects template, which it does when template has one of tags.
// Workflows are never selected, as a workflow cannot run as a step.
func selectedByTags(template *templates.Template, tags []string) bool {
if len(template.Workflows) > 0 {
return false
}
return slices.ContainsFunc(template.Info.Tags.ToSlice(), func(tag string) bool {
return slices.Contains(tags, tag)
})
}
func (w *workflowLoader) GetTemplatePaths(templatesList []string, noValidate bool) []string {
includedTemplates, errs := w.options.Catalog.GetTemplatesPath(templatesList)
for template, err := range errs {
@@ -141,27 +108,12 @@ func (w *workflowLoader) GetTemplatePaths(templatesList []string, noValidate boo
loadedTemplates := make([]string, 0, len(templatesPathMap))
for templatePath := range templatesPathMap {
matched, err := w.options.Parser.LoadTemplate(templatePath, w.filters(noValidate), nil, w.options.Catalog)
matched, err := w.options.Parser.LoadTemplate(templatePath, w.tagFilter, nil, w.options.Catalog)
if err != nil && !matched {
gologger.Warning().Msg(err.Error())
} else if matched {
} else if matched || noValidate {
loadedTemplates = append(loadedTemplates, templatePath)
}
}
return loadedTemplates
}
// filters returns the filters that decide which templates a workflow step
// loads. With noValidate, set for a step whose subtemplates loaded, only the
// exclusions apply: the step must load for its subtemplates to run, but an
// excluded template never runs. Excluded severities do not apply either: a
// step with subtemplates does not report its own results, so excluding its
// severity would only keep its subtemplates from running. A late
// out-of-band (interactsh) match of the step is the exception: it is
// reported whatever its severity, as with -s.
func (w *workflowLoader) filters(noValidate bool) *templates.TagFilter {
if noValidate {
return w.exclusions
}
return w.tagFilter
}
+2 -86
View File
@@ -1,8 +1,6 @@
package workflow
import (
"maps"
"slices"
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog"
@@ -10,12 +8,8 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/stringslice"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/dns"
"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/utils/stats"
"github.com/projectdiscovery/nuclei/v3/pkg/workflows"
"github.com/stretchr/testify/require"
)
@@ -69,87 +63,9 @@ func TestGetTemplatePathsByTagsReadsTemplatesDirectoryOnce(t *testing.T) {
require.NoError(t, err)
walksBeforeLookups := cat.walks
require.Equal(t, []string{"wordpress.yaml"}, loader.GetTemplatePathsByTags([]string{"wordpress"}, false))
require.Equal(t, []string{"jira.yaml"}, loader.GetTemplatePathsByTags([]string{"jira"}, false))
require.Equal(t, []string{"wordpress.yaml"}, loader.GetTemplatePathsByTags([]string{"wordpress"}))
require.Equal(t, []string{"jira.yaml"}, loader.GetTemplatePathsByTags([]string{"jira"}))
require.Equal(t, 1, cat.walks-walksBeforeLookups, "templates directory walked per lookup")
require.Equal(t, map[string]int{"wordpress.yaml": 1, "jira.yaml": 1}, parser.parses, "templates parsed per lookup")
}
func TestGetTemplatePathsByTagsNoValidateAppliesExclusionsButSeverity(t *testing.T) {
template := func(id string, tags ...string) *templates.Template {
return &templates.Template{ID: id, Info: model.Info{
Name: id,
Authors: stringslice.StringSlice{Value: "author"},
Tags: stringslice.StringSlice{Value: tags},
SeverityHolder: severity.Holder{Severity: severity.Info},
}}
}
excludedSeverity := template("excluded-severity", "step")
excludedSeverity.Info.SeverityHolder.Severity = severity.Low
excludedProtocol := template("excluded-protocol", "step")
excludedProtocol.RequestsDNS = []*dns.Request{{}}
workflow := template("workflow", "step")
workflow.Workflows = []*workflows.WorkflowTemplate{{Template: "selected.yaml"}}
parser := &countingParser{
templates: map[string]*templates.Template{
"selected.yaml": template("selected", "step"),
"included.yaml": template("included", "step", "included"),
"excluded-tag.yaml": template("excluded-tag", "step", "excluded"),
"excluded-id.yaml": template("excluded-id", "step"),
"excluded-severity.yaml": excludedSeverity,
"excluded-protocol.yaml": excludedProtocol,
"untagged.yaml": template("untagged", "other"),
"workflow.yaml": workflow,
},
parses: make(map[string]int),
}
options := types.DefaultOptions()
options.IncludeIds = []string{"untagged"}
options.ExcludeTags = []string{"excluded", "included"}
options.IncludeTags = []string{"included"}
options.ExcludeIds = []string{"excluded-id"}
options.ExcludeSeverities = severity.Severities{severity.Low}
options.ExcludeProtocols = templateTypes.ProtocolTypes{templateTypes.DNSProtocol}
cat := &countingCatalog{paths: slices.Collect(maps.Keys(parser.templates))}
loader, err := NewLoader(&protocols.ExecutorOptions{Options: options, Catalog: cat, Parser: parser})
require.NoError(t, err)
require.Empty(t, loader.GetTemplatePathsByTags([]string{"step"}, false))
loaded := loader.GetTemplatePathsByTags([]string{"step"}, true)
require.ElementsMatch(t, []string{"selected.yaml", "included.yaml", "excluded-severity.yaml"}, loaded)
}
func TestGetTemplatePathsByTagsCountsSyntaxWarningsOfLoadedTemplates(t *testing.T) {
// a template without severity has a syntax warning
unrated := &templates.Template{ID: "unrated", Info: model.Info{
Name: "unrated",
Authors: stringslice.StringSlice{Value: "author"},
Tags: stringslice.StringSlice{Value: "step"},
}}
tests := []struct {
name string
ids []string
warnings int64
}{
{name: "loaded", warnings: 1},
{name: "filtered out", ids: []string{"other"}, warnings: 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
parser := &countingParser{
templates: map[string]*templates.Template{"unrated.yaml": unrated},
parses: make(map[string]int),
}
options := types.DefaultOptions()
options.IncludeIds = tc.ids
cat := &countingCatalog{paths: []string{"unrated.yaml"}}
loader, err := NewLoader(&protocols.ExecutorOptions{Options: options, Catalog: cat, Parser: parser})
require.NoError(t, err)
before := stats.GetValue(templates.TemplateSyntaxWarningStats)
loader.GetTemplatePathsByTags([]string{"step"}, false)
require.Equal(t, tc.warnings, stats.GetValue(templates.TemplateSyntaxWarningStats)-before)
})
}
}
-15
View File
@@ -8,8 +8,6 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/stringslice"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/marker"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
)
type schemaMetadata struct {
@@ -115,19 +113,6 @@ type Info struct {
Remediation string `json:"remediation,omitempty" yaml:"remediation,omitempty" jsonschema:"title=remediation steps for the template,description=In-depth explanation on how to fix the issues found by the template,example=Change the default administrative username and password of Apache ActiveMQ by editing the file jetty-realm.properties,type=string"`
}
// MarshalJSON encodes the info with every map nested in Metadata keyed by strings.
func (i Info) MarshalJSON() ([]byte, error) {
// info has the fields of Info but not this method, so marshaling it does not recurse.
type info Info
encoded := info(i)
metadata, err := types.ToJSONMap(i.Metadata)
if err != nil {
return nil, err
}
encoded.Metadata = metadata
return json.Marshal(encoded)
}
// TextField is a free-text field of the info, labeled as reports show it.
type TextField struct {
Label string
-28
View File
@@ -35,34 +35,6 @@ func TestInfoJsonMarshal(t *testing.T) {
require.Equal(t, expected, string(result))
}
func TestInfoJsonMarshalNestedYAMLMetadata(t *testing.T) {
var info Info
err := yaml.Unmarshal([]byte(`
name: Nested metadata
author: pdteam
tags: misc
severity: info
metadata:
labels:
en: Hello
1: one
items:
- name: first
tags:
fr: Bonjour
plain: value
`), &info)
require.NoError(t, err)
require.IsType(t, map[interface{}]interface{}{}, info.Metadata["labels"])
for _, value := range []interface{}{info, &info} {
result, err := json.Marshal(value)
require.NoError(t, err)
require.JSONEq(t, `{"name":"Nested metadata","author":["pdteam"],"tags":["misc"],"severity":"info","metadata":{"labels":{"en":"Hello","1":"one"},"items":[{"name":"first","tags":{"fr":"Bonjour"}}],"plain":"value"}}`, string(result))
}
require.IsType(t, map[interface{}]interface{}{}, info.Metadata["labels"], "marshaling must not modify the info")
}
func TestInfoYamlMarshal(t *testing.T) {
info := Info{
Name: "Test Template Name",
+3 -7
View File
@@ -4,13 +4,9 @@ package model
// WorkflowLoader is a loader interface required for workflow initialization.
type WorkflowLoader interface {
// GetTemplatePathsByTags returns the paths of the templates in the
// templates directory that have one of tags. noValidate is as in
// GetTemplatePaths.
GetTemplatePathsByTags(tags []string, noValidate bool) []string
// GetTemplatePathsByTags returns a list of template paths based on the provided tags from the templates directory
GetTemplatePathsByTags(tags []string) []string
// GetTemplatePaths takes a list of templates and returns paths for them.
// With noValidate, only the exclusion filters apply, except excluded
// severities: excluded tags, IDs, protocols and paths.
// GetTemplatePaths takes a list of templates and returns paths for them
GetTemplatePaths(templatesList []string, noValidate bool) []string
}
@@ -3,8 +3,6 @@ package dsl
import (
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
@@ -46,12 +44,6 @@ func networkTestDNS(t *testing.T, address string) (string, *atomic.Int32) {
func networkTestOptions(t *testing.T, options *types.Options) *types.Options {
t.Helper()
// fastdialer adds the nameservers of the system resolver file before the
// stub resolvers and picks them round robin, so the stub answer would
// depend on the host. An empty resolver file keeps only the stub.
resolverFile := filepath.Join(t.TempDir(), "resolv.conf")
require.NoError(t, os.WriteFile(resolverFile, nil, 0o600))
t.Setenv("RESOLVERS_PATH", resolverFile)
if options.ExecutionId == "" {
options.ExecutionId = t.Name()
}
-16
View File
@@ -21,22 +21,6 @@ type Client interface {
Complete(ctx context.Context, prompt string, asJSON bool) (string, error)
}
// Judge is implemented by clients whose provider classifies natively. Its
// confidence is a calibrated probability rather than a number the model
// reports about itself, and the inputs travel apart from the question, so
// matchers use it over Complete when it is available.
type Judge interface {
Judge(ctx context.Context, question string, labels, inputs []string) (Verdict, error)
}
// Verdict is the label a Judge chose. Model is the exact model version that
// answered, when the provider reports one.
type Verdict struct {
Label string
Confidence float64
Model string
}
// TruncateApproxTokens trims input to roughly maxTokens using the common
// 4-chars-per-token approximation. Exact counting is provider-specific; this
// only needs to keep a large body from blowing the context window.
+300
View File
@@ -0,0 +1,300 @@
package operators
import (
"strings"
"github.com/projectdiscovery/nuclei/v3/pkg/operators/matchers"
)
// Confidence tiers reported alongside a result. They express how reliable the
// detection is, independent of severity (how bad the issue is). The split
// follows the Burp "tentative/firm/certain" and OpenVAS QoD models.
const (
ConfidenceLow = "low"
ConfidenceMedium = "medium"
ConfidenceHigh = "high"
)
// confidence is reported on a 0-100 scale modelled on OpenVAS "Quality of
// Detection" (QoD). Tier thresholds align with the scale; OpenVAS uses 70 as its
// default trustworthy cutoff, which sits inside the medium band here.
const (
confidenceMediumThreshold = 50
confidenceHighThreshold = 80
// ConfidenceCertain is the score for out-of-band confirmed detections
// (e.g. an interactsh interaction): unambiguous proof, so the maximum.
ConfidenceCertain = 100
// ConfidenceCatchAll is the clamped score for matches that also fire against
// the host's catch-all baseline: a strong false-positive signal.
ConfidenceCatchAll = 10
)
// Base reliability of each evidence class. The ordering follows the black-box
// scanner literature and QoD detection methods: matching on a status code or
// response size is the most false-positive prone form of inference (an HTTP 500
// can be any error, a banner can be backported), matching content with a
// word is moderate, and matching content with a regex/dsl/binary/xpath is the
// strongest in-band signal. Out-of-band confirmation (handled by the caller)
// outranks all of these.
const (
weightStatusOrSize = 25
// weightLLM scores an llm matcher. A model verdict is a judgement rather
// than a deterministic check of the response, so it sits below word
// matching and needs deterministic matchers to reach a higher tier.
weightLLM = 40
weightWordHeader = 45
weightStrongHeader = 55
weightWordContent = 60
weightStrongContent = 75
// corroboration adds confidence only across DISTINCT, AND-combined evidence
// classes (independent confirmation), with diminishing returns.
corroborationPerClass = 10
corroborationCap = 20
negativeMatcherBonus = 5
extractorBonus = 3
// multiPatternBonus rewards a single matcher that requires several distinct
// patterns to all be present (condition: and / match-all). Needing many
// independent strings is far harder to trigger by accident than one.
multiPatternBonus = 8
)
// dslWeakIndicators are DSL variables that only inspect response metadata.
// dslContentIndicators signal that a DSL expression actually inspects the
// response body/headers, which is a far stronger detection method.
var (
dslWeakIndicators = []string{"status_code", "content_length", "duration"}
dslContentIndicators = []string{"body", "header", "all_headers", "contains", "regex", "len(", "tolower", "toupper", "title", "hash", "md5", "sha", "base64", "html", "response", "data"}
)
// dslWeakOnly reports whether a DSL matcher only inspects status/size metadata.
// Such a matcher is a "strong" matcher type but carries status-level reliability
// (an HTTP 500 can be any error), so it must not be scored as body evidence.
func dslWeakOnly(m *matchers.Matcher) bool {
if len(m.DSL) == 0 {
return false
}
weak := false
for _, expr := range m.DSL {
e := strings.ToLower(expr)
for _, c := range dslContentIndicators {
if strings.Contains(e, c) {
return false
}
}
for _, w := range dslWeakIndicators {
if strings.Contains(e, w) {
weak = true
}
}
}
return weak
}
// requiresAllPatterns reports whether every pattern in the matcher must be
// present (condition: and or match-all), rather than any single one.
func requiresAllPatterns(m *matchers.Matcher) bool {
return m.MatchAll || strings.EqualFold(m.Condition, "and")
}
// patternCount counts the independent content patterns a matcher carries.
// Status/size codes are intentionally excluded: matching any of several status
// codes broadens rather than narrows a match.
func patternCount(m *matchers.Matcher) int {
return len(m.Words) + len(m.Regex) + len(m.Binary) + len(m.DSL) + len(m.XPath)
}
// metadataParts are response parts that carry weak, false-positive-prone signal
// across every protocol: protocol metadata (status/size/rcode/duration), HTTP
// response headers (the generic part and the common header fields used directly)
// and request-side echoes. Matching these is more backportable / accidental than
// matching real response content. Everything not listed here (response bodies,
// DNS answer/ns/extra sections, TLS certificate fields, command stdout/stderr,
// websocket/whois/javascript payloads, ...) is treated as content, so non-HTTP
// protocols are scored on the strength of what they actually inspect.
var metadataParts = map[string]struct{}{
"header": {}, "all_headers": {}, "headers": {},
"status": {}, "status_code": {}, "size": {}, "content_length": {}, "duration": {},
"rcode": {},
"request": {}, "host": {}, "ip": {}, "port": {}, "matched": {}, "type": {},
// common HTTP response header fields that templates match on directly
"content_type": {}, "content-type": {}, "server": {}, "location": {},
"set_cookie": {}, "set-cookie": {}, "www_authenticate": {}, "www-authenticate": {},
"x_powered_by": {}, "x-powered-by": {}, "cache_control": {}, "cache-control": {},
}
// stripIndexSuffix removes the _<n> suffix that flow / multi-request templates
// append to response parts (body_2, header_3, content_type_2) so they classify
// the same as their base part.
func stripIndexSuffix(p string) string {
i := strings.LastIndexByte(p, '_')
if i <= 0 || i == len(p)-1 {
return p
}
for j := i + 1; j < len(p); j++ {
if p[j] < '0' || p[j] > '9' {
return p
}
}
return p[:i]
}
// isContentPart reports whether a matcher part inspects response content rather
// than protocol metadata or headers. Content is the default: only the known
// weak metadata parts are excluded. Content matches are harder to trigger by
// accident, so they carry higher confidence weight.
func isContentPart(part string) bool {
p := stripIndexSuffix(strings.ToLower(strings.TrimSpace(part)))
_, meta := metadataParts[p]
return !meta
}
// evidenceClass identifies an independent line of evidence. Corroboration is
// rewarded across distinct classes, not redundant repeats of the same weak
// matcher, so three status matchers do not masquerade as strong evidence.
type evidenceClass struct {
kind string
content bool
}
// classify returns the evidence class and base reliability weight for a matcher.
func classify(m *matchers.Matcher) (evidenceClass, int) {
content := isContentPart(m.Part)
switch m.Type.MatcherType {
case matchers.StatusMatcher:
return evidenceClass{kind: "status"}, weightStatusOrSize
case matchers.SizeMatcher:
return evidenceClass{kind: "size"}, weightStatusOrSize
case matchers.WordsMatcher:
if content {
return evidenceClass{kind: "word", content: true}, weightWordContent
}
return evidenceClass{kind: "word"}, weightWordHeader
case matchers.LLMMatcher:
// one class whatever part it reads: several llm matchers are not
// independent evidence, but an llm matcher AND-combined with a
// deterministic matcher still counts as corroboration.
return evidenceClass{kind: "llm"}, weightLLM
case matchers.DSLMatcher:
if dslWeakOnly(m) {
// a dsl that only checks status_code/content_length is metadata
// inference, not body evidence, despite being a "strong" type.
return evidenceClass{kind: "status"}, weightStatusOrSize
}
if content {
return evidenceClass{kind: "strong", content: true}, weightStrongContent
}
return evidenceClass{kind: "strong"}, weightStrongHeader
default: // regex, binary, xpath
if content {
return evidenceClass{kind: "strong", content: true}, weightStrongContent
}
return evidenceClass{kind: "strong"}, weightStrongHeader
}
}
// Confidence computes a static, deterministic detection-confidence score (0-100)
// and tier from the template's matcher composition. It rewards strong matcher
// techniques, content-level matching, independent corroboration across distinct
// AND-combined evidence classes and negative matchers, while capping redundant
// weak matchers. Hidden (internal) helper matchers are ignored. The score is
// derived from the template definition alone and sends no additional traffic;
// out-of-band confirmation is folded in by the caller.
func (operators *Operators) Confidence() (int, string) {
if operators == nil || len(operators.Matchers) == 0 {
// extraction-only or matcher-less detections carry the weakest signal
return 0, ConfidenceLow
}
// positive (direct) evidence and negative (absence) evidence are tracked
// separately: a negative matcher behaves as a false-positive guard when it
// accompanies positive evidence, but is the detection itself when it stands
// alone (e.g. token-spray validity checks, "setting reverted" CVE steps).
classes := make(map[evidenceClass]struct{})
best := 0
hasMultiPattern := false
negClasses := make(map[evidenceClass]struct{})
negBest := 0
negMultiPattern := false
negCount := 0
for _, m := range operators.Matchers {
if m == nil || m.Internal {
// internal matchers feed dynamic extraction chains and are hidden
// from output, so they are not evidence for the user-facing match.
continue
}
class, weight := classify(m)
// a single matcher that requires several distinct content patterns to
// all hold is highly specific. Status/size and weak-dsl matchers are
// excluded (weight gate) since matching any of several codes broadens.
// An llm matcher gets no bonus: its inputs are not independent patterns.
multi := weight > weightStatusOrSize && class.kind != "llm" && requiresAllPatterns(m) && patternCount(m) > 1
if m.Negative {
negCount++
negClasses[class] = struct{}{}
if weight > negBest {
negBest = weight
}
if multi {
negMultiPattern = true
}
continue
}
classes[class] = struct{}{}
if weight > best {
best = weight
}
if multi {
hasMultiPattern = true
}
}
// a negative matcher is a guard only when it backs up positive evidence.
negativeAsGuard := best > 0 && negCount > 0
if best == 0 {
// no positive evidence: the absence assertions are the detection. Score
// them by their own weight rather than discarding the match entirely.
best, classes, hasMultiPattern = negBest, negClasses, negMultiPattern
}
if best == 0 {
return 0, ConfidenceLow
}
score := best
if hasMultiPattern {
score += multiPatternBonus
}
// Independent corroboration raises confidence, but only when every matcher
// must hold (AND) and only across distinct evidence classes.
if operators.matchersCondition == matchers.ANDCondition && len(classes) > 1 {
bonus := (len(classes) - 1) * corroborationPerClass
if bonus > corroborationCap {
bonus = corroborationCap
}
score += bonus
}
if negativeAsGuard {
score += negativeMatcherBonus
}
if len(operators.Extractors) > 0 {
score += extractorBonus
}
if score > ConfidenceCertain {
score = ConfidenceCertain
}
return score, ScoreToTier(score)
}
// ScoreToTier maps a 0-100 confidence score to its tier.
func ScoreToTier(score int) string {
switch {
case score >= confidenceHighThreshold:
return ConfidenceHigh
case score >= confidenceMediumThreshold:
return ConfidenceMedium
default:
return ConfidenceLow
}
}
+470
View File
@@ -0,0 +1,470 @@
package operators
import (
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/operators/extractors"
"github.com/projectdiscovery/nuclei/v3/pkg/operators/matchers"
"github.com/stretchr/testify/require"
)
func matcher(t matchers.MatcherType, part string) *matchers.Matcher {
return &matchers.Matcher{Type: matchers.MatcherTypeHolder{MatcherType: t}, Part: part}
}
func TestConfidenceTier(t *testing.T) {
tests := []struct {
name string
operators *Operators
wantTier string
}{
{
name: "nil operators",
operators: nil,
wantTier: ConfidenceLow,
},
{
name: "no matchers",
operators: &Operators{},
wantTier: ConfidenceLow,
},
{
name: "single status matcher is low",
operators: &Operators{
Matchers: []*matchers.Matcher{matcher(matchers.StatusMatcher, "")},
},
wantTier: ConfidenceLow,
},
{
name: "redundant status matchers stay low",
operators: &Operators{
matchersCondition: matchers.ANDCondition,
Matchers: []*matchers.Matcher{
matcher(matchers.StatusMatcher, ""),
matcher(matchers.StatusMatcher, ""),
matcher(matchers.StatusMatcher, ""),
},
},
wantTier: ConfidenceLow,
},
{
name: "single body word is medium",
operators: &Operators{
Matchers: []*matchers.Matcher{matcher(matchers.WordsMatcher, "body")},
},
wantTier: ConfidenceMedium,
},
{
name: "single body regex is medium",
operators: &Operators{
Matchers: []*matchers.Matcher{matcher(matchers.RegexMatcher, "body")},
},
wantTier: ConfidenceMedium,
},
{
name: "or condition takes strongest matcher",
operators: &Operators{
matchersCondition: matchers.ORCondition,
Matchers: []*matchers.Matcher{
matcher(matchers.StatusMatcher, ""),
matcher(matchers.DSLMatcher, "body"),
},
},
wantTier: ConfidenceMedium,
},
{
name: "distinct and-combined classes reach high",
operators: &Operators{
matchersCondition: matchers.ANDCondition,
Matchers: []*matchers.Matcher{
matcher(matchers.WordsMatcher, "body"),
matcher(matchers.RegexMatcher, "body"),
},
},
wantTier: ConfidenceHigh,
},
{
name: "status with extractor stays low",
operators: &Operators{
Matchers: []*matchers.Matcher{matcher(matchers.StatusMatcher, "")},
Extractors: []*extractors.Extractor{{}},
},
wantTier: ConfidenceLow,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, tier := tt.operators.Confidence()
require.Equal(t, tt.wantTier, tier)
})
}
}
// TestConfidenceCorroboration verifies that confidence rises only with distinct
// independent evidence, not with redundant repeats of the same matcher class.
func TestConfidenceCorroboration(t *testing.T) {
distinct := &Operators{
matchersCondition: matchers.ANDCondition,
Matchers: []*matchers.Matcher{
matcher(matchers.RegexMatcher, "body"),
matcher(matchers.WordsMatcher, "header"),
matcher(matchers.StatusMatcher, ""),
},
}
redundant := &Operators{
matchersCondition: matchers.ANDCondition,
Matchers: []*matchers.Matcher{
matcher(matchers.RegexMatcher, "body"),
matcher(matchers.RegexMatcher, "body"),
matcher(matchers.RegexMatcher, "body"),
},
}
distinctScore, _ := distinct.Confidence()
redundantScore, _ := redundant.Confidence()
require.Greater(t, distinctScore, redundantScore, "distinct evidence classes must outscore redundant repeats of one class")
}
// TestConfidenceNegativeMatcherBonus verifies a negative matcher (explicit
// exclusion of generic/error noise) raises confidence over an identical match
// without it.
func TestConfidenceNegativeMatcherBonus(t *testing.T) {
base := &Operators{Matchers: []*matchers.Matcher{matcher(matchers.RegexMatcher, "body")}}
withNegative := &Operators{
matchersCondition: matchers.ANDCondition,
Matchers: []*matchers.Matcher{
matcher(matchers.RegexMatcher, "body"),
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher}, Part: "body", Negative: true},
},
}
baseScore, _ := base.Confidence()
negScore, _ := withNegative.Confidence()
require.Greater(t, negScore, baseScore, "negative matcher should add a specificity bonus")
}
// TestConfidenceNegativeMatcherIsNotPositiveEvidence verifies that a negative
// (absence) guard does not contribute its matcher weight as positive evidence.
// A status-only match guarded by "body must not contain an error string" is
// still only a status code match and must stay low confidence.
func TestConfidenceNegativeMatcherIsNotPositiveEvidence(t *testing.T) {
guarded := &Operators{
matchersCondition: matchers.ANDCondition,
Matchers: []*matchers.Matcher{
matcher(matchers.StatusMatcher, ""),
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.RegexMatcher}, Part: "body", Negative: true},
},
}
score, tier := guarded.Confidence()
require.Equal(t, ConfidenceLow, tier, "a strong negative guard must not lift a status-only match out of low")
require.LessOrEqual(t, score, weightStatusOrSize+negativeMatcherBonus, "negative matcher weight must not count as positive evidence")
}
// TestConfidenceIgnoresInternalMatchers verifies hidden helper matchers used for
// dynamic extraction do not count as user-facing evidence.
func TestConfidenceIgnoresInternalMatchers(t *testing.T) {
ops := &Operators{
matchersCondition: matchers.ANDCondition,
Matchers: []*matchers.Matcher{
matcher(matchers.StatusMatcher, ""),
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.DSLMatcher}, Part: "body", Internal: true},
},
}
score, tier := ops.Confidence()
require.Equal(t, weightStatusOrSize, score, "internal matcher must be ignored, leaving only the status weight")
require.Equal(t, ConfidenceLow, tier)
}
// neg builds a negative matcher of the given type/part.
func neg(t matchers.MatcherType, part string) *matchers.Matcher {
return &matchers.Matcher{Type: matchers.MatcherTypeHolder{MatcherType: t}, Part: part, Negative: true}
}
// TestConfidenceRealWorldScenarios pins the score band and tier for matcher
// compositions that mirror real nuclei templates, so the scorer stays sensible
// as it evolves.
func TestConfidenceRealWorldScenarios(t *testing.T) {
tests := []struct {
name string
operators *Operators
minScore int
maxScore int
wantTier string
}{
{
name: "host-up status probe",
operators: &Operators{
Matchers: []*matchers.Matcher{matcher(matchers.StatusMatcher, "")},
},
minScore: 1, maxScore: weightStatusOrSize, wantTier: ConfidenceLow,
},
{
name: "tech detection single header banner",
operators: &Operators{
Matchers: []*matchers.Matcher{matcher(matchers.WordsMatcher, "header")},
},
minScore: weightWordHeader, maxScore: weightWordHeader, wantTier: ConfidenceLow,
},
{
name: "exposed panel title word",
operators: &Operators{
Matchers: []*matchers.Matcher{matcher(matchers.WordsMatcher, "body")},
},
minScore: weightWordContent, maxScore: weightWordContent, wantTier: ConfidenceMedium,
},
{
name: "favicon hash dsl",
operators: &Operators{
Matchers: []*matchers.Matcher{matcher(matchers.DSLMatcher, "")},
},
minScore: weightStrongContent, maxScore: weightStrongContent, wantTier: ConfidenceMedium,
},
{
name: "cve body regex and status with extractor",
operators: &Operators{
matchersCondition: matchers.ANDCondition,
Matchers: []*matchers.Matcher{
matcher(matchers.RegexMatcher, "body"),
matcher(matchers.StatusMatcher, ""),
},
Extractors: []*extractors.Extractor{{}},
},
minScore: confidenceHighThreshold, maxScore: ConfidenceCertain, wantTier: ConfidenceHigh,
},
{
name: "strong body match guarded by negative error page",
operators: &Operators{
matchersCondition: matchers.ANDCondition,
Matchers: []*matchers.Matcher{
matcher(matchers.WordsMatcher, "body"),
neg(matchers.WordsMatcher, "body"),
},
},
minScore: weightWordContent, maxScore: weightWordContent + negativeMatcherBonus, wantTier: ConfidenceMedium,
},
{
name: "fingerprint or any of several strong signals",
operators: &Operators{
matchersCondition: matchers.ORCondition,
Matchers: []*matchers.Matcher{
matcher(matchers.RegexMatcher, "body"),
matcher(matchers.DSLMatcher, "body"),
matcher(matchers.WordsMatcher, "header"),
},
},
minScore: weightStrongContent, maxScore: weightStrongContent, wantTier: ConfidenceMedium,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score, tier := tt.operators.Confidence()
require.GreaterOrEqual(t, score, tt.minScore, "score below expected band")
require.LessOrEqual(t, score, tt.maxScore, "score above expected band")
require.Equal(t, tt.wantTier, tier)
})
}
}
// TestConfidenceNegativeOnlyIsEvidence verifies that when a detection has no
// positive matchers, a sole negative (absence) matcher is scored as the
// detection itself rather than discarded. This mirrors token-spray validity
// checks and "setting reverted" CVE steps.
func TestConfidenceNegativeOnlyIsEvidence(t *testing.T) {
negativeOnly := &Operators{Matchers: []*matchers.Matcher{
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher}, Part: "body", Words: []string{"Access Token Invalid"}, Negative: true},
}}
positive := &Operators{Matchers: []*matchers.Matcher{matcher(matchers.WordsMatcher, "body")}}
negScore, negTier := negativeOnly.Confidence()
posScore, _ := positive.Confidence()
require.Equal(t, weightWordContent, negScore, "a sole negative body matcher should score as a body match")
require.Equal(t, ConfidenceMedium, negTier)
require.Equal(t, posScore, negScore, "negative-only evidence should match the equivalent positive matcher weight")
}
// TestConfidenceMultiPatternBonus verifies a single matcher requiring several
// distinct content patterns (condition: and) outscores the same matcher with a
// single pattern, while a broad any-of (or) matcher gets no bonus.
func TestConfidenceMultiPatternBonus(t *testing.T) {
single := &Operators{Matchers: []*matchers.Matcher{
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher}, Part: "body", Words: []string{"a"}},
}}
multiAnd := &Operators{Matchers: []*matchers.Matcher{
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher}, Part: "body", Words: []string{"a", "b", "c"}, Condition: "and"},
}}
multiOr := &Operators{Matchers: []*matchers.Matcher{
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher}, Part: "body", Words: []string{"a", "b", "c"}, Condition: "or"},
}}
matchAll := &Operators{Matchers: []*matchers.Matcher{
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher}, Part: "body", Words: []string{"a", "b"}, MatchAll: true},
}}
singleScore, _ := single.Confidence()
multiAndScore, _ := multiAnd.Confidence()
multiOrScore, _ := multiOr.Confidence()
matchAllScore, _ := matchAll.Confidence()
require.Greater(t, multiAndScore, singleScore, "all-of multi-pattern matcher should outscore single pattern")
require.Equal(t, singleScore, multiOrScore, "any-of multi-pattern matcher should not earn the bonus")
require.Greater(t, matchAllScore, singleScore, "match-all matcher should earn the multi-pattern bonus")
}
// TestConfidenceDSLWeakOnly verifies a dsl matcher that only inspects status or
// size metadata is scored as weak as a status match, while a dsl that inspects
// the body keeps strong-content reliability.
func TestConfidenceDSLWeakOnly(t *testing.T) {
weak := &Operators{Matchers: []*matchers.Matcher{
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.DSLMatcher}, DSL: []string{"status_code == 200"}},
}}
weakCombo := &Operators{Matchers: []*matchers.Matcher{
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.DSLMatcher}, DSL: []string{"status_code == 200 && content_length > 10"}},
}}
strong := &Operators{Matchers: []*matchers.Matcher{
{Type: matchers.MatcherTypeHolder{MatcherType: matchers.DSLMatcher}, DSL: []string{"contains(body, 'SecretMarker') && status_code == 200"}},
}}
weakScore, weakTier := weak.Confidence()
weakComboScore, _ := weakCombo.Confidence()
strongScore, strongTier := strong.Confidence()
require.Equal(t, weightStatusOrSize, weakScore, "metadata-only dsl must score as a status match")
require.Equal(t, ConfidenceLow, weakTier)
require.Equal(t, weightStatusOrSize, weakComboScore, "status+size-only dsl is still metadata inference")
require.Equal(t, weightStrongContent, strongScore, "body-inspecting dsl keeps strong-content weight")
require.Equal(t, ConfidenceMedium, strongTier)
}
// TestIsContentPartAcrossProtocols verifies the part classifier recognizes
// content vs metadata parts for every protocol, including numbered suffixes
// emitted by multi-request / flow templates.
func TestIsContentPartAcrossProtocols(t *testing.T) {
content := []string{
"", "body", "all", "response", "raw", "data", // http / generic
"body_2", "body_5", "response_2", // multi-request numbered
"answer", "question", "ns", "extra", "trace", "a", "aaaa", "cname", // dns
"stderr", "code_1_response", // code
"subject_dn", "issuer_cn", "fingerprint", "cipher", "domains", // ssl
"history", // headless
"domain", "registrar", // whois
}
metadata := []string{
"header", "all_headers", "header_2", "header_3", // http headers
"content_type", "content_type_2", "server", "location", "set_cookie", // header fields
"status", "status_code", "size", "content_length", "duration", // http metadata
"rcode", // dns metadata
"request", "host", "ip", "port", "matched", "type", // request-side
}
for _, p := range content {
require.True(t, isContentPart(p), "part %q should be content", p)
}
for _, p := range metadata {
require.False(t, isContentPart(p), "part %q should be metadata", p)
}
}
// TestConfidenceNonHTTPContentParts verifies that content matches on non-HTTP
// protocols (dns answer, code stderr, tls cert fields) are scored as content,
// not weak metadata.
func TestConfidenceNonHTTPContentParts(t *testing.T) {
for _, part := range []string{"answer", "stderr", "subject_dn", "code_1_response"} {
ops := &Operators{Matchers: []*matchers.Matcher{matcher(matchers.WordsMatcher, part)}}
score, tier := ops.Confidence()
require.Equal(t, weightWordContent, score, "word match on %q should score as content", part)
require.Equal(t, ConfidenceMedium, tier)
}
}
func TestScoreToTier(t *testing.T) {
require.Equal(t, ConfidenceLow, ScoreToTier(0))
require.Equal(t, ConfidenceLow, ScoreToTier(confidenceMediumThreshold-1))
require.Equal(t, ConfidenceMedium, ScoreToTier(confidenceMediumThreshold))
require.Equal(t, ConfidenceHigh, ScoreToTier(confidenceHighThreshold))
require.Equal(t, ConfidenceHigh, ScoreToTier(ConfidenceCertain))
}
func TestConfidenceLLMMatcher(t *testing.T) {
llm := func() *matchers.Matcher {
m := matcher(matchers.LLMMatcher, "body")
m.Prompt = "is this an exposed admin panel?"
return m
}
llmMulti := func() *matchers.Matcher {
m := llm()
m.Condition = "and"
m.MatchAll = true
m.Words = []string{"admin", "panel"}
return m
}
tests := []struct {
name string
operators *Operators
wantScore int
wantTier string
}{
{
name: "single llm matcher",
operators: &Operators{Matchers: []*matchers.Matcher{llm()}},
wantScore: weightLLM,
wantTier: ConfidenceLow,
},
{
name: "llm matcher gets no multi-pattern bonus",
operators: &Operators{Matchers: []*matchers.Matcher{llmMulti()}},
wantScore: weightLLM,
wantTier: ConfidenceLow,
},
{
name: "two llm matchers are one evidence class",
operators: &Operators{
matchersCondition: matchers.ANDCondition,
Matchers: []*matchers.Matcher{llm(), llm()},
},
wantScore: weightLLM,
wantTier: ConfidenceLow,
},
{
name: "llm AND body word adds corroboration",
operators: &Operators{
matchersCondition: matchers.ANDCondition,
Matchers: []*matchers.Matcher{llm(), matcher(matchers.WordsMatcher, "body")},
},
wantScore: weightWordContent + corroborationPerClass,
wantTier: ConfidenceMedium,
},
{
name: "llm AND status adds corroboration",
operators: &Operators{
matchersCondition: matchers.ANDCondition,
Matchers: []*matchers.Matcher{llm(), matcher(matchers.StatusMatcher, "")},
},
wantScore: weightLLM + corroborationPerClass,
wantTier: ConfidenceMedium,
},
{
name: "llm AND body regex AND status caps corroboration",
operators: &Operators{
matchersCondition: matchers.ANDCondition,
Matchers: []*matchers.Matcher{llm(), matcher(matchers.RegexMatcher, "body"), matcher(matchers.StatusMatcher, "")},
},
wantScore: weightStrongContent + corroborationCap,
wantTier: ConfidenceHigh,
},
{
name: "llm OR body word takes the best class only",
operators: &Operators{
Matchers: []*matchers.Matcher{llm(), matcher(matchers.WordsMatcher, "body")},
},
wantScore: weightWordContent,
wantTier: ConfidenceMedium,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
score, tier := tc.operators.Confidence()
require.Equal(t, tc.wantScore, score)
require.Equal(t, tc.wantTier, tier)
})
}
}
-9
View File
@@ -130,12 +130,3 @@ func TestCompileExtractorsIgnoresXPathOnOtherTypes(t *testing.T) {
}
require.NoError(t, e.CompileExtractors())
}
func TestCompileExtractorsXPathUndeclaredVariable(t *testing.T) {
// Regression test: this expression used to compile and then panic
// with a nil pointer dereference inside antchfx/xpath when the
// first response was evaluated. It must fail at compile time.
var operator Extractor
require.NoError(t, yaml.Unmarshal([]byte("type: xpath\nxpath:\n - \"******$A\"\n"), &operator))
require.ErrorContains(t, operator.CompileExtractors(), "undeclared variable")
}
-9
View File
@@ -53,12 +53,3 @@ func TestCompileMatchersAllowsEmptyStringValue(t *testing.T) {
matched, _ := m.MatchWords("body", nil)
require.True(t, matched)
}
func TestCompileMatchersDSLTrailingBackslash(t *testing.T) {
// Regression test: a DSL expression whose string literal ends in a
// backslash used to panic the govaluate lexer (index out of range)
// while a template was loading. It must surface as a compile error.
var operator Matcher
require.NoError(t, yaml.Unmarshal([]byte("type: dsl\ndsl:\n - \"body == 'x\\\\'\"\n"), &operator))
require.ErrorContains(t, operator.CompileMatchers(), "Unclosed string literal")
}
+17 -58
View File
@@ -65,21 +65,26 @@ func (matcher *Matcher) MatchLLMWithAudit(corpus string, inputs []string, values
return false, nil, nil
}
responses := matcher.truncate(corpus, inputs)
var (
verdict llmVerdict
audit *LLMAudit
ok bool
)
if judge, isJudge := client.(llmclient.Judge); isJudge {
verdict, audit, ok = matcher.judgeLLM(judge, responses, values)
} else {
verdict, audit, ok = matcher.completeLLM(client, responses, values)
}
if !ok {
prompt := matcher.buildLLMPrompt(matcher.truncate(corpus, inputs), values)
answer, err := client.Complete(context.Background(), prompt, true)
if err != nil {
return false, nil, nil
}
var verdict llmVerdict
if err := json.Unmarshal([]byte(answer), &verdict); err != nil {
return false, nil, nil
}
audit := &LLMAudit{
PromptHash: hashPrompt(prompt),
Verdict: strings.TrimSpace(verdict.Verdict),
Confidence: verdict.Confidence,
}
if namer, ok := client.(modelNamer); ok {
audit.Model = namer.Model()
}
expect := matcher.Expect
if expect == "" {
expect = "yes"
@@ -102,55 +107,9 @@ func (matcher *Matcher) MatchLLMWithAudit(corpus string, inputs []string, values
return false, nil, audit
}
if verdict.Evidence == "" {
return true, nil, audit
}
return true, []string{verdict.Evidence}, audit
}
// completeLLM asks a chat model for the verdict as JSON.
func (matcher *Matcher) completeLLM(client LLMClient, inputs []string, values map[string]interface{}) (llmVerdict, *LLMAudit, bool) {
prompt := matcher.buildLLMPrompt(inputs, values)
answer, err := client.Complete(context.Background(), prompt, true)
if err != nil {
return llmVerdict{}, nil, false
}
var verdict llmVerdict
if err := json.Unmarshal([]byte(answer), &verdict); err != nil {
return llmVerdict{}, nil, false
}
audit := &LLMAudit{
PromptHash: hashPrompt(prompt),
Verdict: strings.TrimSpace(verdict.Verdict),
Confidence: verdict.Confidence,
}
if namer, ok := client.(modelNamer); ok {
audit.Model = namer.Model()
}
return verdict, audit, true
}
// judgeLLM asks a native classifier. It needs no prompt framing: the question
// and the responses are separate fields of the request, and the answer is a
// probability over the allowed verdicts, not text. It quotes no evidence.
func (matcher *Matcher) judgeLLM(judge llmclient.Judge, inputs []string, values map[string]interface{}) (llmVerdict, *LLMAudit, bool) {
question := interpolate(matcher.Prompt, values)
answer, err := judge.Judge(context.Background(), question, matcher.Options, inputs)
if err != nil {
return llmVerdict{}, nil, false
}
audit := &LLMAudit{
Model: answer.Model,
PromptHash: hashPrompt(strings.Join(append([]string{question, strings.Join(matcher.Options, ",")}, inputs...), "\x00")),
Verdict: answer.Label,
Confidence: answer.Confidence,
}
return llmVerdict{Verdict: answer.Label, Confidence: answer.Confidence}, audit, true
}
// buildLLMPrompt wraps the author's question with the output contract and the
// response under a random boundary (see FrameResponse).
//
-59
View File
@@ -6,7 +6,6 @@ import (
"regexp"
"testing"
llmclient "github.com/projectdiscovery/nuclei/v3/pkg/operators/common/llm"
"github.com/stretchr/testify/require"
)
@@ -278,61 +277,3 @@ func TestMatchLLMWithoutInputsUsesPart(t *testing.T) {
require.Contains(t, stub.lastGot, "the body")
require.NotContains(t, stub.lastGot, "Response 1")
}
type stubJudge struct {
stubLLM
verdict llmclient.Verdict
err error
question string
labels []string
inputs []string
}
func (s *stubJudge) Judge(_ context.Context, question string, labels, inputs []string) (llmclient.Verdict, error) {
s.question, s.labels, s.inputs = question, labels, inputs
return s.verdict, s.err
}
func TestMatchLLMPrefersJudgeAndKeepsResponseOutOfQuestion(t *testing.T) {
m := llmMatcher("login page for {{Hostname}}?")
judge := &stubJudge{verdict: llmclient.Verdict{Label: "yes", Confidence: 0.97, Model: "jev-1.14.0"}}
m.SetLLMClient(judge)
ok, snips, audit := m.MatchLLMWithAudit("<form>", nil, map[string]interface{}{"Hostname": "example.com"})
require.True(t, ok)
require.Empty(t, snips, "a classifier quotes no evidence")
require.Zero(t, judge.calls, "Complete must not be used when the client can judge")
require.Equal(t, "login page for example.com?", judge.question)
require.Nil(t, judge.labels, "no options means a yes or no question")
require.Equal(t, []string{"<form>"}, judge.inputs)
require.Equal(t, "jev-1.14.0", audit.Model)
require.InDelta(t, 0.97, audit.Confidence, 1e-9)
}
func TestMatchLLMJudgeAppliesExpectOptionsAndConfidence(t *testing.T) {
m := llmMatcher("same access?")
m.Options = []string{"same", "different"}
m.Expect = "different"
m.MinConfidence = 0.9
judge := &stubJudge{verdict: llmclient.Verdict{Label: "different", Confidence: 0.8}}
m.SetLLMClient(judge)
ok, _, audit := m.MatchLLMWithAudit("", []string{"a", "b"}, nil)
require.False(t, ok, "below min-confidence")
require.NotNil(t, audit)
require.Equal(t, []string{"same", "different"}, judge.labels)
require.Equal(t, []string{"a", "b"}, judge.inputs)
judge.verdict.Confidence = 0.95
ok, _, _ = m.MatchLLMWithAudit("", []string{"a", "b"}, nil)
require.True(t, ok)
}
func TestMatchLLMJudgeFailsClosed(t *testing.T) {
m := llmMatcher("q")
m.SetLLMClient(&stubJudge{err: errors.New("boom")})
ok, _, audit := m.MatchLLMWithAudit("body", nil, nil)
require.False(t, ok)
require.Nil(t, audit)
}
+19
View File
@@ -9,6 +9,18 @@ import (
mapsutil "github.com/projectdiscovery/utils/maps"
)
// confidenceColor colorizes confidence text using the tier color.
func (w *StandardWriter) confidenceColor(text, tier string) string {
switch tier {
case "high":
return w.aurora.BrightGreen(text).String()
case "medium":
return w.aurora.BrightYellow(text).String()
default:
return w.aurora.BrightBlack(text).String()
}
}
// formatScreen formats the output for showing on screen.
func (w *StandardWriter) formatScreen(output *ResultEvent) []byte {
builder := &bytes.Buffer{}
@@ -51,6 +63,13 @@ func (w *StandardWriter) formatScreen(output *ResultEvent) []byte {
builder.WriteString("[")
builder.WriteString(w.severityColors(output.Info.SeverityHolder.Severity))
builder.WriteString("] ")
if output.Confidence != "" {
label := output.Confidence + " (" + strconv.Itoa(output.ConfidenceScore) + ")"
builder.WriteString("[")
builder.WriteString(w.confidenceColor(label, output.Confidence))
builder.WriteString("] ")
}
}
if output.Matched != "" {
builder.WriteString(output.Matched)
+42 -108
View File
@@ -87,13 +87,11 @@ type StandardWriter struct {
DisableStdout bool
AddNewLinesOutputFile bool // by default this is only done for stdout
KeysToRedact []string
// NoRedact skips the default credential keys; KeysToRedact still apply.
NoRedact bool
// redactSet caches the compiled credential-redaction regexes so they
// redactPatterns caches the compiled credential-redaction regexes so they
// are not recompiled on every Write call.
redactPatternsOnce sync.Once
redactSet compiledRedaction
redactPatterns []*regexp.Regexp
// JSONLogRequestHook is a hook that can be used to log request/response
// when using custom server code with output
@@ -127,10 +125,6 @@ type InternalWrappedEvent struct {
// Only applicable if interactsh is used
// This is used to avoid duplicate successful interactsh events
InteractshMatched atomic.Bool
// CatchAll reports whether the response is the page the host serves for
// any missing path. It probes the host on first call, so it is only asked
// once a template matched. Nil when calibration is off or does not apply.
CatchAll func() bool
}
func (iwe *InternalWrappedEvent) CloneShallow() *InternalWrappedEvent {
@@ -139,7 +133,6 @@ func (iwe *InternalWrappedEvent) CloneShallow() *InternalWrappedEvent {
Results: nil,
OperatorsResult: nil,
UsesInteractsh: iwe.UsesInteractsh,
CatchAll: iwe.CatchAll,
}
}
@@ -220,6 +213,12 @@ type ResultEvent struct {
CURLCommand string `json:"curl-command,omitempty"`
// MatcherStatus is the status of the match
MatcherStatus bool `json:"matcher-status"`
// Confidence is a heuristic detection-reliability tier (low/medium/high),
// derived from matcher composition and out-of-band confirmation, and is
// independent of severity.
Confidence string `json:"confidence,omitempty"`
// ConfidenceScore is the 0-100 score (QoD-style) behind Confidence.
ConfidenceScore int `json:"confidence-score"`
// Lines is the line count for the specified match
Lines []int `json:"matched-line,omitempty"`
// GlobalMatchers identifies whether the matches was detected in the response
@@ -251,20 +250,6 @@ type ResultEvent struct {
TemplateInfo *model.Info `json:"-" bson:"-"`
}
// MarshalJSON encodes the event with every map nested in Metadata keyed by
// strings. Metadata can hold javascript args decoded from template YAML.
func (r ResultEvent) MarshalJSON() ([]byte, error) {
// resultEvent has the fields of ResultEvent but not this method, so marshaling it does not recurse.
type resultEvent ResultEvent
encoded := resultEvent(r)
metadata, err := types.ToJSONMap(r.Metadata)
if err != nil {
return nil, err
}
encoded.Metadata = metadata
return json.Marshal(encoded)
}
type IssueTrackerMetadata struct {
// IssueID is the ID of the issue created
IssueID string `json:"id,omitempty"`
@@ -327,7 +312,6 @@ func NewStandardWriter(options *types.Options) (*StandardWriter, error) {
storeResponseDir: options.StoreResponseDir,
omitTemplate: options.OmitTemplate,
KeysToRedact: options.Redact,
NoRedact: options.NoRedact,
}
if v := os.Getenv("DISABLE_STDOUT"); v == "true" || v == "1" {
@@ -387,12 +371,12 @@ func (w *StandardWriter) Write(event *ResultEvent) error {
// user-supplied -redact keys. patterns are compiled once and cached to
// avoid recompiling regexes on every event in the write hot path.
patterns := w.redactionPatterns()
event.Request = redactWithSet(event.Request, patterns)
event.Response = redactWithSet(event.Response, patterns)
event.CURLCommand = redactWithSet(event.CURLCommand, patterns)
event.Matched = redactWithSet(event.Matched, patterns)
event.Request = redactWithPatterns(event.Request, patterns)
event.Response = redactWithPatterns(event.Response, patterns)
event.CURLCommand = redactWithPatterns(event.CURLCommand, patterns)
event.Matched = redactWithPatterns(event.Matched, patterns)
for _, field := range event.Info.FilledFields(event.TemplateInfo) {
*field.Value = redactWithSet(*field.Value, patterns)
*field.Value = redactWithPatterns(*field.Value, patterns)
}
event.Timestamp = time.Now()
@@ -434,8 +418,8 @@ func (w *StandardWriter) Write(event *ResultEvent) error {
return nil
}
// defaultRedactKeys are header and parameter names that are redacted from
// output regardless of the -redact flag, unless -no-redact is set.
// defaultRedactKeys are header and parameter names that are always redacted
// from output regardless of the -redact flag.
var defaultRedactKeys = []string{
"Authorization",
"Proxy-Authorization",
@@ -444,94 +428,38 @@ var defaultRedactKeys = []string{
"X-Api-Key",
}
// cookieHeaderValue is the value of one Cookie or Set-Cookie header, stopping
// at a quote so a curl -H 'Cookie: ...' does not swallow the rest of the line.
var cookieHeaderValue = regexp.MustCompile(`(?i)((?:Cookie|Set-Cookie)\s*:\s*)([^"'\r\n]*)`)
// compiledRedaction holds the three shapes a key can take. A header value is
// masked to the end of the line. Inside a cookie header, key=value stops at
// the next cookie delimiter so neighbouring cookies stay. A query value keeps
// an embedded ';' and runs to '&', a quote, or whitespace.
type compiledRedaction struct {
headers []*regexp.Regexp
cookies []*regexp.Regexp
queries []*regexp.Regexp
}
func (c compiledRedaction) empty() bool {
return len(c.headers) == 0 && len(c.cookies) == 0 && len(c.queries) == 0
}
// defaultRedactPatterns are the precompiled regexes for defaultRedactKeys.
var defaultRedactPatterns = compileRedactPatterns(defaultRedactKeys)
// compileRedactPatterns builds the credential-redaction regexes for the given keys.
func compileRedactPatterns(keys []string) compiledRedaction {
var set compiledRedaction
func compileRedactPatterns(keys []string) []*regexp.Regexp {
patterns := make([]*regexp.Regexp, 0, len(keys))
for _, key := range keys {
quoted := regexp.QuoteMeta(key)
set.headers = append(set.headers, regexp.MustCompile(fmt.Sprintf(`(?i)(%s\s*:\s*["']?)([^"'&\r\n]+)(["'\r\n]|$)`, quoted)))
set.cookies = append(set.cookies, regexp.MustCompile(fmt.Sprintf(`(?i)(%s\s*=\s*["']?)([^"'&\r\n\s]*?)(;\s*[\w.-]+(?:=|;|\r|\n|$)|["'&\r\n\s]|$)`, quoted)))
set.queries = append(set.queries, regexp.MustCompile(fmt.Sprintf(`(?i)(%s\s*=\s*["']?)([^"'&\r\n\s]*)(["'&\r\n\s]|$)`, quoted)))
patterns = append(patterns, regexp.MustCompile(fmt.Sprintf(`(?i)(%s\s*[:=]\s*["']?)[^"'\r\n&]+(["'\r\n]?)`, regexp.QuoteMeta(key))))
}
return set
return patterns
}
// redactionPatterns returns the cached redaction set for this writer (default
// keys unless -no-redact is set, plus any user-supplied -redact keys).
func (w *StandardWriter) redactionPatterns() compiledRedaction {
// redactionPatterns returns the cached set of redaction patterns for this
// writer (default keys plus any user-supplied -redact keys), compiling them on
// first use.
func (w *StandardWriter) redactionPatterns() []*regexp.Regexp {
w.redactPatternsOnce.Do(func() {
keys := make([]string, 0, len(defaultRedactKeys)+len(w.KeysToRedact))
if !w.NoRedact {
keys = append(keys, defaultRedactKeys...)
if len(w.KeysToRedact) == 0 {
w.redactPatterns = defaultRedactPatterns
return
}
keys = append(keys, w.KeysToRedact...)
w.redactSet = compileRedactPatterns(keys)
patterns := make([]*regexp.Regexp, 0, len(defaultRedactPatterns)+len(w.KeysToRedact))
patterns = append(patterns, defaultRedactPatterns...)
patterns = append(patterns, compileRedactPatterns(w.KeysToRedact)...)
w.redactPatterns = patterns
})
return w.redactSet
return w.redactPatterns
}
func redactKeys(data string, keys []string) string {
return redactWithSet(data, compileRedactPatterns(keys))
}
func redactWithSet(data string, set compiledRedaction) string {
if data == "" || set.empty() {
return data
}
data = applyRedact(data, set.headers)
if len(set.cookies) > 0 {
data = cookieHeaderValue.ReplaceAllStringFunc(data, func(match string) string {
parts := cookieHeaderValue.FindStringSubmatch(match)
if len(parts) != 3 {
return match
}
return parts[1] + applyRedact(parts[2], set.cookies)
})
}
return redactOutsideCookieValues(data, set.queries)
}
func redactOutsideCookieValues(data string, patterns []*regexp.Regexp) string {
if len(patterns) == 0 {
return data
}
locs := cookieHeaderValue.FindAllStringSubmatchIndex(data, -1)
if len(locs) == 0 {
return applyRedact(data, patterns)
}
var b strings.Builder
prev := 0
for _, loc := range locs {
valueStart, valueEnd := loc[4], loc[5]
b.WriteString(applyRedact(data[prev:valueStart], patterns))
b.WriteString(data[valueStart:valueEnd])
prev = valueEnd
}
b.WriteString(applyRedact(data[prev:], patterns))
return b.String()
}
func applyRedact(data string, patterns []*regexp.Regexp) string {
func redactWithPatterns(data string, patterns []*regexp.Regexp) string {
for _, pattern := range patterns {
data = pattern.ReplaceAllString(data, `$1***$3`)
data = pattern.ReplaceAllString(data, `$1***$2`)
}
return data
}
@@ -642,6 +570,10 @@ func (w *StandardWriter) Close() {
}
}
// ConfidenceNone is the confidence of a failure event: no matcher fired, so
// there is no detection and its confidence score is 0.
const ConfidenceNone = "none"
// WriteFailure writes the failure event for template to file and/or screen.
func (w *StandardWriter) WriteFailure(wrappedEvent *InternalWrappedEvent) error {
if !w.matcherStatus {
@@ -692,7 +624,9 @@ func (w *StandardWriter) WriteFailure(wrappedEvent *InternalWrappedEvent) error
Request: types.ToString(event["request"]),
Response: types.ToString(event["response"]),
MatcherStatus: false,
Timestamp: time.Now(),
// nothing matched, so there is no detection to be confident about
Confidence: ConfidenceNone,
Timestamp: time.Now(),
//FIXME: this is workaround to encode the template when no results were found
TemplateEncoded: w.encodeTemplate(types.ToString(event["template-path"])),
Error: types.ToString(event["error"]),
+43 -118
View File
@@ -1,6 +1,7 @@
package output
import (
"encoding/json"
"fmt"
"strings"
"testing"
@@ -9,8 +10,6 @@ import (
"github.com/projectdiscovery/goflags"
"github.com/projectdiscovery/nuclei/v3/pkg/model"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson"
)
@@ -56,6 +55,48 @@ func TestStandardWriterRequest(t *testing.T) {
})
}
func TestFormatScreenIncludesConfidenceScore(t *testing.T) {
w, err := NewStandardWriter(&types.Options{NoColor: true})
require.NoError(t, err)
t.Cleanup(func() { w.Close() })
formatted := string(w.formatScreen(&ResultEvent{
TemplateID: "test-template",
Type: "http",
Host: "example.com",
Confidence: "low",
ConfidenceScore: 0,
}))
require.Contains(t, formatted, "[low (0)]")
}
func TestResultEventSerializesZeroConfidenceScore(t *testing.T) {
data, err := json.Marshal(&ResultEvent{Confidence: "low", ConfidenceScore: 0})
require.NoError(t, err)
require.Contains(t, string(data), `"confidence-score":0`)
}
func TestWriteFailureReportsNoConfidence(t *testing.T) {
w, err := NewStandardWriter(&types.Options{JSONL: true, MatcherStatus: true})
require.NoError(t, err)
out := &testWriteCloser{}
w.outputFile = out
w.DisableStdout = true
err = w.WriteFailure(&InternalWrappedEvent{InternalEvent: InternalEvent{
"template-id": "failed-template",
"host": "https://example.com",
"type": "http",
}})
require.NoError(t, err)
var event map[string]interface{}
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out.String())), &event))
require.Equal(t, false, event["matcher-status"])
require.Equal(t, ConfidenceNone, event["confidence"])
require.Equal(t, float64(0), event["confidence-score"])
}
// Only the fields filled from a response are redacted; text the template wrote
// stays as it is, even next to a filled field.
func TestStandardWriterRedactsFilledInfo(t *testing.T) {
@@ -74,80 +115,6 @@ func TestStandardWriterRedactsFilledInfo(t *testing.T) {
require.Equal(t, "send the password: header", static.Info.Description)
}
// Credential headers are masked by default; -no-redact keeps them while keys
// given with -redact are still masked.
func TestStandardWriterNoRedact(t *testing.T) {
const request = "GET /?tracking=1&x=2 HTTP/1.1\r\nCookie: tracking=1; session=abc\r\nX-Token: secret\r\n\r\n"
const response = "HTTP/1.1 200 OK\r\nSet-Cookie: session=abc; Path=/\r\n\r\n"
const curl = "curl -H 'Cookie: tracking=1; session=abc' https://example.com/?tracking=1&x=2"
const matched = "https://example.com/?tracking=1&x=2"
cases := []struct {
name string
options *types.Options
request string
response string
curl string
matched string
}{
{
"default", &types.Options{},
"GET /?tracking=1&x=2 HTTP/1.1\r\nCookie: ***\r\nX-Token: secret\r\n\r\n",
"HTTP/1.1 200 OK\r\nSet-Cookie: ***\r\n\r\n",
"curl -H 'Cookie: ***' https://example.com/?tracking=1&x=2",
matched,
},
{"no-redact", &types.Options{NoRedact: true}, request, response, curl, matched},
{
"no-redact with header key", &types.Options{NoRedact: true, Redact: goflags.StringSlice{"X-Token"}},
"GET /?tracking=1&x=2 HTTP/1.1\r\nCookie: tracking=1; session=abc\r\nX-Token: ***\r\n\r\n",
response, curl, matched,
},
// One cookie or query parameter is masked; its neighbours stay.
{
"no-redact with cookie key", &types.Options{NoRedact: true, Redact: goflags.StringSlice{"tracking"}},
"GET /?tracking=***&x=2 HTTP/1.1\r\nCookie: tracking=***; session=abc\r\nX-Token: secret\r\n\r\n",
response,
"curl -H 'Cookie: tracking=***; session=abc' https://example.com/?tracking=***&x=2",
"https://example.com/?tracking=***&x=2",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w, err := NewStandardWriter(tc.options)
require.NoError(t, err)
w.DisableStdout = true
event := &ResultEvent{Request: request, Response: response, CURLCommand: curl, Matched: matched}
require.NoError(t, w.Write(event))
require.Equal(t, tc.request, event.Request)
require.Equal(t, tc.response, event.Response)
require.Equal(t, tc.curl, event.CURLCommand)
require.Equal(t, tc.matched, event.Matched)
})
}
}
// A `;` inside a query value is part of the value; in a cookie header it is a delimiter.
func TestRedactKeepsSemicolonInsideValue(t *testing.T) {
cases := map[string]string{
"GET /?password=;s3cr3t&x=2 HTTP/1.1": "GET /?password=***&x=2 HTTP/1.1",
"GET /?password=a;b HTTP/1.1": "GET /?password=*** HTTP/1.1",
"GET /?password=super;secret=hidden&x=1 HTTP/1.1": "GET /?password=***&x=1 HTTP/1.1",
"https://example.com/?password=a;b": "https://example.com/?password=***",
"https://example.com/?password=a;session=abc": "https://example.com/?password=***",
"Cookie: password=x; session=abc\r\n": "Cookie: password=***; session=abc\r\n",
"Set-Cookie: password=x; Path=/; HttpOnly\r\n": "Set-Cookie: password=***; Path=/; HttpOnly\r\n",
"Set-Cookie: password=x; HttpOnly\r\n": "Set-Cookie: password=***; HttpOnly\r\n",
"password=a;b&user=u": "password=***&user=u",
"curl -H 'Cookie: password=x; session=abc' 'https://example.com/?password=super;secret=hidden&x=1'": "curl -H 'Cookie: password=***; session=abc' 'https://example.com/?password=***&x=1'",
}
for in, want := range cases {
require.Equal(t, want, redactKeys(in, []string{"password"}), in)
}
}
// The mongo exporter serializes results with bson, which ignores json tags.
func TestResultEventKeepsTemplateInfoOutOfBSON(t *testing.T) {
written := model.Info{Description: "{{host}} is exposed"}
@@ -160,48 +127,6 @@ func TestResultEventKeepsTemplateInfoOutOfBSON(t *testing.T) {
require.Contains(t, doc, "info")
}
// Template YAML decodes nested maps with interface keys, in the info metadata and
// in the javascript args a result carries as its metadata.
func TestFormatJSONNestedYAMLMaps(t *testing.T) {
var template struct {
Info model.Info `yaml:"info"`
Args map[string]interface{} `yaml:"args"`
}
err := yaml.Unmarshal([]byte(`
info:
name: Nested metadata
author: repro
severity: info
metadata:
labels:
en: Hello
items:
- fr: Bonjour
args:
Nested:
en: Hello
`), &template)
require.NoError(t, err)
w, err := NewStandardWriter(&types.Options{JSONL: true})
require.NoError(t, err)
data, err := w.formatJSON(&ResultEvent{TemplateID: "nested-metadata", Info: template.Info, Metadata: template.Args})
require.NoError(t, err)
var decoded struct {
Info struct {
Metadata map[string]interface{} `json:"metadata"`
} `json:"info"`
Meta map[string]interface{} `json:"meta"`
}
require.NoError(t, json.Unmarshal(data, &decoded))
require.Equal(t, map[string]interface{}{
"labels": map[string]interface{}{"en": "Hello"},
"items": []interface{}{map[string]interface{}{"fr": "Bonjour"}},
}, decoded.Info.Metadata)
require.Equal(t, map[string]interface{}{"Nested": map[string]interface{}{"en": "Hello"}}, decoded.Meta)
}
type testWriteCloser struct {
strings.Builder
}
+75
View File
@@ -0,0 +1,75 @@
// Package baseline implements per-host catch-all detection used to lower the
// confidence of likely false-positive matches.
//
// Many hosts answer every request with the same "catch-all" page (custom 404s,
// SPA shells, WAF interstitials, login walls). Templates whose matchers fire on
// such a page produce false positives. The approach here follows the black-box
// scanner literature (x8, Metasploit Differential, ReScan): learn the host's
// response to a request that almost certainly does not exist, then check whether
// a template's matchers ALSO fire on that baseline. Unlike body-diffing schemes,
// replaying the operators is binary and robust: if a matcher matches a known
// non-existent path, the detection is not specific to the real target.
package baseline
import "sync"
// maxHosts bounds the number of cached baselines to keep memory predictable on
// very large scans; beyond it, baselines are still fetched but not retained.
const maxHosts = 10000
// Map is a learned baseline: the DSL field map produced from the control
// response, suitable for replaying a template's compiled operators against.
type Map = map[string]interface{}
// Cache learns and stores a per-host catch-all baseline so it can be replayed
// against every template without re-fetching. Each host is fetched at most once,
// even under concurrent access.
type Cache struct {
mu sync.Mutex
entries map[string]*entry
}
type entry struct {
once sync.Once
data Map
ok bool
}
// New returns an empty baseline cache.
func New() *Cache {
return &Cache{entries: make(map[string]*entry)}
}
// GetOrFetch returns the baseline for host, invoking fetch exactly once per host
// to learn it. fetch should return the DSL map of the control response; a nil
// map or error marks the host as having no usable baseline. The second return
// value reports whether a usable baseline is available.
func (c *Cache) GetOrFetch(host string, fetch func() (Map, error)) (Map, bool) {
if c == nil || host == "" {
return nil, false
}
c.mu.Lock()
e, found := c.entries[host]
if !found {
// Don't grow the cache without bound. Over the cap we fetch directly so
// detection still works, we just don't retain the baseline.
if len(c.entries) >= maxHosts {
c.mu.Unlock()
data, err := fetch()
return data, err == nil && data != nil
}
e = &entry{}
c.entries[host] = e
}
c.mu.Unlock()
e.once.Do(func() {
data, err := fetch()
if err == nil && data != nil {
e.data = data
e.ok = true
}
})
return e.data, e.ok
}
@@ -0,0 +1,67 @@
package baseline
import (
"errors"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
)
func TestGetOrFetchCachesPerHost(t *testing.T) {
cache := New()
var calls atomic.Int32
fetch := func() (Map, error) {
calls.Add(1)
return Map{"status_code": 200}, nil
}
for i := 0; i < 5; i++ {
data, ok := cache.GetOrFetch("https://example.com", fetch)
require.True(t, ok)
require.Equal(t, 200, data["status_code"])
}
require.Equal(t, int32(1), calls.Load(), "baseline should be fetched once per host")
cache.GetOrFetch("https://other.com", fetch)
require.Equal(t, int32(2), calls.Load(), "distinct host should trigger a new fetch")
}
func TestGetOrFetchFetchesOnceUnderConcurrency(t *testing.T) {
cache := New()
var calls atomic.Int32
fetch := func() (Map, error) {
calls.Add(1)
return Map{"ok": true}, nil
}
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
cache.GetOrFetch("https://example.com", fetch)
}()
}
wg.Wait()
require.Equal(t, int32(1), calls.Load(), "concurrent access must fetch the baseline exactly once")
}
func TestGetOrFetchErrorNotCached(t *testing.T) {
cache := New()
_, ok := cache.GetOrFetch("https://example.com", func() (Map, error) {
return nil, errors.New("boom")
})
require.False(t, ok, "failed fetch should report no usable baseline")
}
func TestGetOrFetchNilReceiverOrEmptyHost(t *testing.T) {
var cache *Cache
_, ok := cache.GetOrFetch("https://example.com", func() (Map, error) { return Map{}, nil })
require.False(t, ok)
cache = New()
_, ok = cache.GetOrFetch("", func() (Map, error) { return Map{}, nil })
require.False(t, ok)
}
@@ -38,9 +38,6 @@ func TestEvaluate(t *testing.T) {
{input: "{{base64(rawhash)}}", expected: "{{contact_id}}{{email}}", extra: map[string]any{
"rawhash": `{"contact_id":"{{contact_id}}","email":"{{email}}"}`,
}},
// regression: string concatenation of variables inside base64() must not
// stringify the parameter list (govaluate produced "[admin:]admin" here)
{input: "{{base64(username + ':' + password)}}", expected: "YWRtaW46YWRtaW4=", extra: map[string]interface{}{"username": "admin", "password": "admin"}},
}
for _, item := range items {
value, err := Evaluate(item.input, item.extra)
@@ -1,46 +0,0 @@
package protocolstate
import (
"sync"
"testing"
"github.com/projectdiscovery/networkpolicy"
"github.com/stretchr/testify/require"
)
func TestIsHostAllowedRacesExcludeUpdate(t *testing.T) {
np, err := networkpolicy.New(networkpolicy.Options{DenyList: []string{"127.0.0.0/8"}})
require.NoError(t, err)
const executionID = "exclude-race"
entry := &Dialers{NetworkPolicy: np, ExcludeTargets: []string{"192.0.2.1"}}
err = dialers.Set(executionID, entry)
require.NoError(t, err)
t.Cleanup(func() { dialers.Delete(executionID) })
stop := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
lists := [][]string{{"192.0.2.1"}, {"192.0.2.2", "192.0.2.3"}}
i := 0
for {
select {
case <-stop:
return
default:
}
entry.Lock()
entry.ExcludeTargets = lists[i%2]
entry.Unlock()
i++
}
}()
for i := 0; i < 2000; i++ {
_ = IsHostAllowed(executionID, "http://192.0.2.1")
}
close(stop)
wg.Wait()
}
+35 -69
View File
@@ -102,28 +102,33 @@ func isValidHost(options *types.Options, targetUrl string) bool {
if !stringsutil.HasPrefixAny(normalized, "http:", "https:", "ws:", "wss:") {
return true
}
if options == nil {
dialers, ok := dialers.Get(options.ExecutionId)
if !ok {
return true
}
return IsHostAllowed(options.ExecutionId, targetUrl)
np := dialers.NetworkPolicy
if np == nil {
// no policy present: deny rather than allow.
return false
}
urlx, err := urlutil.Parse(targetUrl)
if err != nil {
// not a valid url
return false
}
targetUrl = urlx.Hostname()
_, ok = np.ValidateHost(targetUrl)
return ok
}
// CanonicalizeHost normalizes a hostname or IP string:
// - strips surrounding whitespace and brackets (e.g. "[::1]" -> "::1")
// - maps IPv4-mapped IPv6 literals (e.g. "::ffff:127.0.0.1" or "[::ffff:127.0.0.1]:80") to canonical IPv4 ("127.0.0.1" or "127.0.0.1:80")
// - maps IPv4-mapped IPv6 literals (e.g. "::ffff:127.0.0.1" or "0:0:0:0:0:ffff:127.0.0.1") to canonical IPv4 ("127.0.0.1")
func CanonicalizeHost(host string) string {
h := strings.TrimSpace(host)
if sh, port, err := net.SplitHostPort(h); err == nil {
sh = strings.TrimPrefix(sh, "[")
sh = strings.TrimSuffix(sh, "]")
if ip := net.ParseIP(sh); ip != nil {
if ipv4 := ip.To4(); ipv4 != nil {
return net.JoinHostPort(ipv4.String(), port)
}
return net.JoinHostPort(ip.String(), port)
}
return h
}
h = strings.TrimPrefix(h, "[")
h = strings.TrimSuffix(h, "]")
if ip := net.ParseIP(h); ip != nil {
@@ -138,14 +143,11 @@ func CanonicalizeHost(host string) string {
// IsHostAllowed checks if the host is allowed by network policy
func IsHostAllowed(executionId string, targetUrl string) bool {
dialers, ok := dialers.Get(executionId)
if !ok || dialers == nil {
if !ok {
return true
}
dialers.Lock()
np := dialers.NetworkPolicy
excludedTargets := append([]string(nil), dialers.ExcludeTargets...)
dialers.Unlock()
if np == nil {
// no policy present: deny rather than allow.
return false
@@ -177,7 +179,7 @@ func IsHostAllowed(executionId string, targetUrl string) bool {
canonHost := CanonicalizeHost(host)
// Check explicit ExcludeTargets against raw, host, and canonical host
for _, excluded := range excludedTargets {
for _, excluded := range dialers.ExcludeTargets {
ex := CanonicalizeHost(strings.TrimSpace(excluded))
if ex != "" {
if strings.EqualFold(canonHost, ex) || strings.EqualFold(host, ex) || strings.EqualFold(rawTarget, ex) {
@@ -186,11 +188,15 @@ func IsHostAllowed(executionId string, targetUrl string) bool {
}
}
// The policy stores IPv4 prefixes. A mapped form such as ::ffff:203.0.113.10
// has to be judged as 203.0.113.10, or an allow-listed address is rejected.
// Validate host against NetworkPolicy using both canonical and original host
if _, ok := np.ValidateHost(canonHost); !ok {
return false
}
if canonHost != host {
if _, ok := np.ValidateHost(host); !ok {
return false
}
}
return true
}
@@ -215,35 +221,14 @@ func SetupHeadlessNetworkPolicy(options *types.Options, page *rod.Page) error {
target = parsed.Hostname()
}
if !strings.Contains(target, "/") {
addPatterns := func(t string) {
t = strings.TrimPrefix(strings.TrimSuffix(t, "]"), "[")
blockedPatterns = append(blockedPatterns,
"*://"+t+"/*",
"*://"+t+":*/*",
"ws://"+t+"/*",
"ws://"+t+":*/*",
"wss://"+t+"/*",
"wss://"+t+":*/*",
)
if ip := net.ParseIP(t); ip != nil {
if ipv4 := ip.To4(); ipv4 != nil {
v6mapped := "[::ffff:" + ipv4.String() + "]"
blockedPatterns = append(blockedPatterns,
"*://"+v6mapped+"/*",
"*://"+v6mapped+":*/*",
"ws://"+v6mapped+"/*",
"ws://"+v6mapped+":*/*",
"wss://"+v6mapped+"/*",
"wss://"+v6mapped+":*/*",
)
}
}
}
addPatterns(target)
canon := CanonicalizeHost(target)
if canon != "" && canon != target {
addPatterns(canon)
}
blockedPatterns = append(blockedPatterns,
"*://"+target+"/*",
"*://"+target+":*/*",
"ws://"+target+"/*",
"ws://"+target+":*/*",
"wss://"+target+"/*",
"wss://"+target+":*/*",
)
}
}
@@ -270,32 +255,13 @@ func SetupHeadlessNetworkPolicy(options *types.Options, page *rod.Page) error {
"wss://10.*/*", "wss://10.*:*/*",
"wss://192.168.*/*", "wss://192.168.*:*/*",
"wss://169.254.*/*", "wss://169.254.*:*/*",
"*://[::ffff:127.*]/*", "*://[::ffff:127.*]:*/*",
"*://[::ffff:0.0.0.0]/*", "*://[::ffff:0.0.0.0]:*/*",
"*://[::ffff:10.*]/*", "*://[::ffff:10.*]:*/*",
"*://[::ffff:192.168.*]/*", "*://[::ffff:192.168.*]:*/*",
"*://[::ffff:169.254.*]/*", "*://[::ffff:169.254.*]:*/*",
"ws://[::ffff:127.*]/*", "ws://[::ffff:127.*]:*/*",
"ws://[::ffff:0.0.0.0]/*", "ws://[::ffff:0.0.0.0]:*/*",
"ws://[::ffff:10.*]/*", "ws://[::ffff:10.*]:*/*",
"ws://[::ffff:192.168.*]/*", "ws://[::ffff:192.168.*]:*/*",
"ws://[::ffff:169.254.*]/*", "ws://[::ffff:169.254.*]:*/*",
"wss://[::ffff:127.*]/*", "wss://[::ffff:127.*]:*/*",
"wss://[::ffff:0.0.0.0]/*", "wss://[::ffff:0.0.0.0]:*/*",
"wss://[::ffff:10.*]/*", "wss://[::ffff:10.*]:*/*",
"wss://[::ffff:192.168.*]/*", "wss://[::ffff:192.168.*]:*/*",
"wss://[::ffff:169.254.*]/*", "wss://[::ffff:169.254.*]:*/*",
}
for i := 16; i <= 31; i++ {
prefix := fmt.Sprintf("172.%d.*", i)
v6prefix := fmt.Sprintf("[::ffff:172.%d.*]", i)
localPatterns = append(localPatterns,
"*://"+prefix+"/*", "*://"+prefix+":*/*",
"*://"+v6prefix+"/*", "*://"+v6prefix+":*/*",
"ws://"+prefix+"/*", "ws://"+prefix+":*/*",
"ws://"+v6prefix+"/*", "ws://"+v6prefix+":*/*",
"wss://"+prefix+"/*", "wss://"+prefix+":*/*",
"wss://"+v6prefix+"/*", "wss://"+v6prefix+":*/*",
)
}
blockedPatterns = append(blockedPatterns, localPatterns...)
@@ -19,8 +19,7 @@ func TestIsValidHost(t *testing.T) {
require.NoError(t, err)
d := &Dialers{
NetworkPolicy: np,
ExcludeTargets: []string{"192.168.1.50"},
NetworkPolicy: np,
}
err = dialers.Set(opts.ExecutionId, d)
require.NoError(t, err)
@@ -32,75 +31,18 @@ func TestIsValidHost(t *testing.T) {
require.True(t, isValidHost(opts, "ws://example.com:9999"))
require.True(t, isValidHost(opts, "wss://example.com:9999"))
// Blocked hosts (127.0.0.1 in DenyList)
// Blocked hosts (127.0.0.1)
require.False(t, isValidHost(opts, "http://127.0.0.1:8080"))
require.False(t, isValidHost(opts, "https://127.0.0.1:8443"))
require.False(t, isValidHost(opts, "ws://127.0.0.1:9999"))
require.False(t, isValidHost(opts, "wss://127.0.0.1:9999"))
// Blocked IPv4-mapped IPv6 forms of 127.0.0.1
require.False(t, isValidHost(opts, "http://[::ffff:127.0.0.1]:8080"))
require.False(t, isValidHost(opts, "https://[::ffff:127.0.0.1]:8443"))
require.False(t, isValidHost(opts, "http://[::ffff:7f00:1]:8080"))
require.False(t, isValidHost(opts, "http://[0:0:0:0:0:ffff:127.0.0.1]:8080"))
require.False(t, isValidHost(opts, "http://[0000:0000:0000:0000:0000:ffff:7f00:0001]:8080"))
require.False(t, isValidHost(opts, "ws://[::ffff:127.0.0.1]:9999"))
// Blocked hosts via ExcludeTargets (IPv4 and IPv4-mapped IPv6)
require.False(t, isValidHost(opts, "http://192.168.1.50:8080"))
require.False(t, isValidHost(opts, "http://[::ffff:192.168.1.50]:8080"))
require.False(t, isValidHost(opts, "http://[::ffff:c0a8:132]:8080"))
require.True(t, IsHostAllowed(opts.ExecutionId, "ws://example.com:9999"))
require.False(t, IsHostAllowed(opts.ExecutionId, "ws://127.0.0.1:9999"))
require.False(t, IsHostAllowed(opts.ExecutionId, "ws://[::ffff:127.0.0.1]:9999"))
require.False(t, IsHostAllowed(opts.ExecutionId, "http://[::ffff:192.168.1.50]:8080"))
}
func TestIsHostAllowedMappedFormOfAllowListedIP(t *testing.T) {
const executionID = "test-allow-mapped"
opts := &types.Options{ExecutionId: executionID}
np, err := networkpolicy.New(networkpolicy.Options{
AllowList: []string{"203.0.113.10"},
DenyList: []string{"127.0.0.1"},
})
require.NoError(t, err)
err = dialers.Set(executionID, &Dialers{NetworkPolicy: np})
require.NoError(t, err)
defer dialers.Delete(executionID)
require.True(t, isValidHost(opts, "http://203.0.113.10"))
require.True(t, isValidHost(opts, "http://[::ffff:203.0.113.10]"))
require.True(t, isValidHost(opts, "http://[::ffff:cb00:710a]:8080"))
require.False(t, isValidHost(opts, "http://[::ffff:127.0.0.1]"))
require.False(t, isValidHost(opts, "http://198.51.100.1"))
}
func TestCanonicalizeHost_IPv4MappedIPv6(t *testing.T) {
cases := map[string]string{
"127.0.0.1": "127.0.0.1",
"127.0.0.1:8080": "127.0.0.1:8080",
"::ffff:127.0.0.1": "127.0.0.1",
"[::ffff:127.0.0.1]": "127.0.0.1",
"[::ffff:127.0.0.1]:8080": "127.0.0.1:8080",
"::ffff:7f00:1": "127.0.0.1",
"[::ffff:7f00:1]:8443": "127.0.0.1:8443",
"0:0:0:0:0:ffff:127.0.0.1": "127.0.0.1",
"0000:0000:0000:0000:0000:ffff:7f00:0001": "127.0.0.1",
"example.com": "example.com",
"example.com:443": "example.com:443",
"::1": "::1",
"[::1]:8080": "[::1]:8080",
}
for input, expected := range cases {
require.Equal(t, expected, CanonicalizeHost(input), "input: %s", input)
}
}
func TestSetupHeadlessNetworkPolicy_Nil(t *testing.T) {
require.NoError(t, SetupHeadlessNetworkPolicy(nil, nil))
require.NoError(t, SetupHeadlessNetworkPolicy(&types.Options{}, nil))
}
+1 -54
View File
@@ -5,14 +5,11 @@ import (
"fmt"
"net"
"net/url"
"regexp"
"slices"
"strings"
"time"
"github.com/go-sql-driver/mysql"
"github.com/pkg/errors"
"golang.org/x/net/idna"
"golang.org/x/net/proxy"
"github.com/projectdiscovery/fastdialer/fastdialer"
@@ -21,7 +18,6 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httpcache"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/expand"
iputil "github.com/projectdiscovery/utils/ip"
mapsutil "github.com/projectdiscovery/utils/maps"
)
@@ -105,20 +101,15 @@ func initDialers(options *types.Options) error {
switch {
case asn.IsASN(excludeTarget):
expandedDenyList = append(expandedDenyList, expand.ASN(excludeTarget)...)
case iputil.IsIP(excludeTarget) || iputil.IsCIDR(excludeTarget):
expandedDenyList = append(expandedDenyList, excludeTarget)
default:
expandedDenyList = append(expandedDenyList, excludeHostRules(excludeTarget)...)
expandedDenyList = append(expandedDenyList, excludeTarget)
}
}
if options.RestrictLocalNetworkAccess {
expandedDenyList = append(expandedDenyList, networkpolicy.DefaultIPv4DenylistRanges...)
expandedDenyList = append(expandedDenyList, networkpolicy.DefaultIPv6DenylistRanges...)
// the unspecified address connects to the local host
expandedDenyList = append(expandedDenyList, "::/128")
}
expandedDenyList = append(expandedDenyList, mappedDenyRanges(expandedDenyList)...)
npOptions := &networkpolicy.Options{
DenyList: expandedDenyList,
}
@@ -344,47 +335,3 @@ func Close(executionId string) {
StopActiveMemGuardian()
}
}
// mappedDenyRanges returns the IPv4-mapped IPv6 form (::ffff:a.b.c.d) of each
// IPv4 address and range in denyList. The network policy checks a mapped
// address such as [::ffff:127.0.0.1] only against IPv6 ranges, but the dialer
// connects to the IPv4 host, so without these forms it passes -eh and -lna.
func mappedDenyRanges(denyList []string) []string {
var mapped []string
for _, entry := range denyList {
cidr := entry
if !strings.Contains(cidr, "/") {
cidr += "/32"
}
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil || ipNet.IP.To4() == nil || strings.Contains(entry, ":") {
continue
}
ones, _ := ipNet.Mask.Size()
mapped = append(mapped, fmt.Sprintf("::ffff:%s/%d", ipNet.IP.To4(), 96+ones))
}
return mapped
}
// excludeHostRules turns an exclude entry into network policy deny rules.
// networkpolicy treats non-IP entries as unanchored regexes, so a plain
// "test.com" would also deny "latest.com". Plain hostnames match the host and
// its subdomains instead; entries with regex syntax are kept as given.
func excludeHostRules(target string) []string {
if strings.ContainsAny(target, `\^$*+?()[]{}|`) {
return []string{target}
}
host := strings.TrimSuffix(target, ".")
hosts := []string{host}
// the network DSL helpers only normalize plain rules, so add the IDNA form here
if ascii, err := idna.ToASCII(strings.ToLower(host)); err == nil && !strings.EqualFold(ascii, host) {
hosts = append(hosts, ascii)
}
rules := make([]string, 0, len(hosts))
for _, h := range hosts {
rules = append(rules, `(?i)(^|\.)`+regexp.QuoteMeta(h)+`\.?$`)
}
return rules
}
@@ -1,78 +0,0 @@
package protocolstate
import (
"context"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/stretchr/testify/require"
)
func TestExcludeTargetsMatchExactHost(t *testing.T) {
opts := types.DefaultOptions()
opts.ExecutionId = "test-exclude-targets"
opts.ExcludeTargets = []string{"test.com", "192.0.2.0/24", `^regex\.example$`, "bücher.example"}
require.NoError(t, Init(opts))
defer Close(opts.ExecutionId)
np := GetDialersWithId(opts.ExecutionId).NetworkPolicy
require.False(t, np.Validate("test.com"))
require.False(t, np.Validate("TEST.com"))
require.False(t, np.Validate("test.com."))
require.False(t, np.Validate("sub.test.com"))
require.False(t, np.Validate("192.0.2.10"))
require.False(t, np.Validate("regex.example"))
require.False(t, np.Validate("xn--bcher-kva.example"))
require.True(t, np.Validate("latest.com"))
require.True(t, np.Validate("test.com.example.org"))
require.True(t, np.Validate("testXcom"))
require.True(t, np.Validate("sub.regex.example"))
}
func TestMappedFormsDeniedByDialer(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer server.Close()
_, port, err := net.SplitHostPort(server.Listener.Addr().String())
require.NoError(t, err)
for name, set := range map[string]func(*types.Options){
"lna": func(o *types.Options) { o.RestrictLocalNetworkAccess = true },
"exclude-hosts": func(o *types.Options) { o.ExcludeTargets = []string{"127.0.0.1"} },
"exclude-cidr": func(o *types.Options) { o.ExcludeTargets = []string{"127.0.0.0/8"} },
} {
t.Run(name, func(t *testing.T) {
opts := types.DefaultOptions()
opts.ExecutionId = "test-mapped-" + name
set(opts)
require.NoError(t, Init(opts))
defer Close(opts.ExecutionId)
d := GetDialersWithId(opts.ExecutionId)
hosts := []string{"127.0.0.1", "::ffff:127.0.0.1", "::ffff:7f00:1", "0:0:0:0:0:ffff:127.0.0.1"}
if opts.RestrictLocalNetworkAccess {
hosts = append(hosts, "::")
}
for _, host := range hosts {
require.False(t, d.NetworkPolicy.Validate(host), host)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
conn, err := d.Fastdialer.Dial(ctx, "tcp", net.JoinHostPort(host, port))
cancel()
if conn != nil {
_ = conn.Close()
}
require.Error(t, err, host)
}
require.True(t, d.NetworkPolicy.Validate("::ffff:8.8.8.8"))
require.True(t, d.NetworkPolicy.Validate("8.8.8.8"))
})
}
}
func TestMappedDenyRanges(t *testing.T) {
require.Equal(t, []string{"::ffff:127.0.0.0/104", "::ffff:192.0.2.10/128", "::ffff:0.0.0.0/104"},
mappedDenyRanges([]string{"127.0.0.0/8", "192.0.2.10", "::1/128", "fc00::/7", `(?i)(^|\.)test\.com\.?$`, "0.0.0.0/8"}))
}
+19 -28
View File
@@ -26,42 +26,33 @@ var defaultResolvers = []string{
"8.8.4.4:53", // Google
}
// Init initializes the client pool implementation.
// Each resolver list gets its own client, so a later scan is not stuck with the first scan's resolvers.
// Init initializes the client pool implementation
func Init(options *types.Options) error {
client, err := cachedClient(resolversFor(options))
m.Lock()
defer m.Unlock()
// Don't create clients if already created in the past.
if normalClient != nil {
return nil
}
clientPool = mapsutil.NewSyncLockMap[string, *retryabledns.Client]()
resolvers := defaultResolvers
if len(options.InternalResolversList) > 0 {
resolvers = options.InternalResolversList
}
var err error
normalClient, err = retryabledns.New(resolvers, 1)
if err != nil {
return errors.Wrap(err, "could not create dns client")
}
m.Lock()
normalClient = client
m.Unlock()
return nil
}
func resolversFor(options *types.Options) []string {
if options != nil && len(options.InternalResolversList) > 0 {
return options.InternalResolversList
}
return defaultResolvers
}
func cachedClient(resolvers []string) (*retryabledns.Client, error) {
func getNormalClient() *retryabledns.Client {
m.Lock()
defer m.Unlock()
if clientPool == nil {
clientPool = mapsutil.NewSyncLockMap[string, *retryabledns.Client]()
}
key := strings.Join(resolvers, ",")
if client, ok := clientPool.Get(key); ok {
return client, nil
}
client, err := retryabledns.New(resolvers, 1)
if err != nil {
return nil, err
}
_ = clientPool.Set(key, client)
return client, nil
return normalClient
}
// Configuration contains the custom configuration options for a client
@@ -90,7 +81,7 @@ func (c *Configuration) Hash() string {
// Get creates or gets a client for the protocol based on custom configuration
func Get(options *types.Options, configuration *Configuration) (*retryabledns.Client, error) {
if (configuration.Retries <= 1) && len(configuration.Resolvers) == 0 {
return cachedClient(resolversFor(options))
return getNormalClient(), nil
}
hash := configuration.Hash()
if client, ok := clientPool.Get(hash); ok {
@@ -1,35 +0,0 @@
package dnsclientpool
import (
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/stretchr/testify/require"
)
func TestClientsFollowResolverList(t *testing.T) {
previousClient := normalClient
previousPool := clientPool
t.Cleanup(func() {
normalClient = previousClient
clientPool = previousPool
})
normalClient = nil
clientPool = nil
firstOptions := &types.Options{InternalResolversList: []string{"192.0.2.1:53"}}
secondOptions := &types.Options{InternalResolversList: []string{"192.0.2.2:53"}}
require.NoError(t, Init(firstOptions))
require.NoError(t, Init(secondOptions))
first, err := Get(firstOptions, &Configuration{})
require.NoError(t, err)
second, err := Get(secondOptions, &Configuration{})
require.NoError(t, err)
require.NotSame(t, first, second)
again, err := Get(firstOptions, &Configuration{})
require.NoError(t, err)
require.Same(t, first, again)
}
+48 -92
View File
@@ -12,7 +12,6 @@ import (
"github.com/go-rod/rod/lib/launcher/flags"
"github.com/pkg/errors"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
fileutil "github.com/projectdiscovery/utils/file"
osutils "github.com/projectdiscovery/utils/os"
@@ -39,7 +38,7 @@ func New(options *types.Options) (*Browser, error) {
var proxyServer *headlessProxy
var err error
var chromeLauncher *launcher.Launcher
chromeLauncher := launcher.New()
if options.CDPEndpoint == "" {
dataStore, err = os.MkdirTemp("", "nuclei-*")
@@ -47,33 +46,58 @@ func New(options *types.Options) (*Browser, error) {
return nil, errors.Wrap(err, "could not create temporary directory")
}
chromeLauncher = chromeLauncher.
Leakless(false).
Set("disable-crash-reporter").
Set("disable-gpu").
Set("disable-notifications").
Set("hide-scrollbars").
Set("ignore-certificate-errors").
Set("ignore-ssl-errors").
Set("incognito").
Set("mute-audio").
Set("window-size", fmt.Sprintf("%d,%d", 1080, 1920)).
Delete("use-mock-keychain").
UserDataDir(dataStore)
if MustDisableSandbox() {
chromeLauncher = chromeLauncher.NoSandbox(true)
}
executablePath, err := os.Executable()
if err != nil {
return nil, err
}
// if musl is used, most likely we are on alpine linux which is not supported by go-rod, so we fallback to default chrome
useMusl, _ := fileutil.UseMusl(executablePath)
if options.UseInstalledChrome || useMusl {
if chromePath, hasChrome := launcher.LookPath(); hasChrome {
chromeLauncher.Bin(chromePath)
} else {
return nil, errors.New("the chrome browser is not installed")
}
}
if options.ShowBrowser {
chromeLauncher = chromeLauncher.Headless(false)
} else {
chromeLauncher = chromeLauncher.Headless(true)
}
proxyServer, err = newHeadlessProxy(options)
if err != nil {
return nil, err
}
chromeLauncher = chromeLauncher.
Proxy(proxyServer.Addr()).
Set("proxy-bypass-list", "<-loopback>")
noSandbox := MustDisableSandbox()
chromeLauncher, err = newChromeLauncher(options, dataStore, proxyServer.Addr(), noSandbox)
if err != nil {
_ = proxyServer.Close()
return nil, err
for k, v := range options.ParseHeadlessOptionalArguments() {
chromeLauncher.Set(flags.Flag(k), v)
}
launcherURL, err = chromeLauncher.Launch()
if err != nil && !noSandbox && !chromeLauncher.Has(flags.NoSandbox) && isSandboxLaunchError(err) {
logger := options.Logger
if logger == nil {
logger = gologger.DefaultLogger
}
logger.Warning().Msgf("Could not start the browser with its sandbox, starting it without sandbox. To keep the sandbox, allow unprivileged user namespaces (for example sysctl kernel.unprivileged_userns_clone=1 or kernel.apparmor_restrict_unprivileged_userns=0) or in docker use a seccomp profile that allows user namespaces (for example --security-opt seccomp=unconfined): %s", sandboxLine(err.Error()))
chromeLauncher.Kill()
chromeLauncher, err = newChromeLauncher(options, dataStore, proxyServer.Addr(), true)
if err == nil {
launcherURL, err = chromeLauncher.Launch()
}
}
if err != nil {
_ = proxyServer.Close()
return nil, err
}
} else {
@@ -119,79 +143,11 @@ func New(options *types.Options) (*Browser, error) {
return engine, nil
}
// MustDisableSandbox reports whether the browser must run without its sandbox.
// Chrome refuses to start sandboxed as root on Linux, so only that case needs
// "--no-sandbox". Other users keep the sandbox; New falls back to no sandbox
// with a warning when the system can not provide one.
// https://github.com/chromium/chromium/blob/c4d3c31083a2e1481253ff2d24298a1dfe19c754/chrome/test/chromedriver/client/chromedriver.py#L209
// MustDisableSandbox determines if the current os and user needs sandbox mode disabled
func MustDisableSandbox() bool {
return mustDisableSandbox(osutils.IsLinux(), os.Geteuid())
}
func mustDisableSandbox(linux bool, euid int) bool {
return linux && euid == 0
}
// newChromeLauncher returns the launcher for a local browser. User headless
// options are applied last, so "-headless-options no-sandbox" still works.
func newChromeLauncher(options *types.Options, dataStore, proxyAddr string, noSandbox bool) (*launcher.Launcher, error) {
chromeLauncher := launcher.New().
Leakless(false).
Set("disable-crash-reporter").
Set("disable-gpu").
Set("disable-notifications").
Set("hide-scrollbars").
Set("ignore-certificate-errors").
Set("ignore-ssl-errors").
Set("incognito").
Set("mute-audio").
Set("window-size", fmt.Sprintf("%d,%d", 1080, 1920)).
Delete("use-mock-keychain").
UserDataDir(dataStore).
// rod adds "--no-sandbox" by default inside containers, so the
// sandbox is always set explicitly.
NoSandbox(noSandbox)
executablePath, err := os.Executable()
if err != nil {
return nil, err
}
// if musl is used, most likely we are on alpine linux which is not supported by go-rod, so we fallback to default chrome
useMusl, _ := fileutil.UseMusl(executablePath)
if options.UseInstalledChrome || useMusl {
if chromePath, hasChrome := launcher.LookPath(); hasChrome {
chromeLauncher.Bin(chromePath)
} else {
return nil, errors.New("the chrome browser is not installed")
}
}
chromeLauncher = chromeLauncher.
Headless(!options.ShowBrowser).
Proxy(proxyAddr).
Set("proxy-bypass-list", "<-loopback>")
for k, v := range options.ParseHeadlessOptionalArguments() {
chromeLauncher.Set(flags.Flag(k), v)
}
return chromeLauncher, nil
}
// isSandboxLaunchError reports whether the browser exited because its
// sandbox could not be set up, for example "No usable sandbox!".
func isSandboxLaunchError(err error) bool {
return err != nil && strings.Contains(strings.ToLower(err.Error()), "sandbox")
}
// sandboxLine returns the browser output line about the sandbox.
func sandboxLine(value string) string {
for _, line := range strings.Split(value, "\n") {
if strings.Contains(strings.ToLower(line), "sandbox") {
return strings.TrimSpace(line)
}
}
return strings.TrimSpace(value)
// linux with root user needs "--no-sandbox" option
// https://github.com/chromium/chromium/blob/c4d3c31083a2e1481253ff2d24298a1dfe19c754/chrome/test/chromedriver/client/chromedriver.py#L209
return osutils.IsLinux()
}
// SetUserAgent sets custom user agent to the browser
-82
View File
@@ -1,82 +0,0 @@
package engine
import (
"maps"
"net/http"
"slices"
"strings"
)
// reservedResponseKeys are the event fields that the headless protocol owns.
// A response header or cookie that normalizes to one of them is dropped, so a
// target cannot spoof them (the HTTP protocol also lets its own fields win).
var reservedResponseKeys = map[string]struct{}{
"template-id": {},
"template-info": {},
"template-path": {},
"host": {},
"matched": {},
"type": {},
"req": {},
"resp": {},
"body": {},
"data": {},
"header": {},
"all_headers": {},
"status_code": {},
"history": {},
"duration": {},
// read by the result event, so a target must not set them
"ip": {},
"path": {},
"request": {},
"error": {},
}
// addResponseVariables exposes response headers and cookies as DSL variables
// (same normalization as the HTTP protocol).
// Header names are lowercased with "-" replaced by "_", e.g. Content-Type -> content_type.
// Cookie names are lowercased as-is.
// browserCookies are the cookies the browser holds for the page, as the
// browser does not report Set-Cookie in intercepted response headers. A
// Set-Cookie of the response wins over a browser cookie, and a header wins
// over a cookie of the same name. Headers that normalize to the same name are
// applied in sorted order, so the result is deterministic. Keys already in
// data and reserved protocol keys are never overwritten.
func addResponseVariables(data map[string]interface{}, resp *http.Response, browserCookies []*http.Cookie) {
if resp == nil {
return
}
vars := make(map[string]string)
for _, cookie := range browserCookies {
vars[strings.ToLower(cookie.Name)] = cookie.Value
}
for _, cookie := range resp.Cookies() {
vars[strings.ToLower(cookie.Name)] = cookie.Value
}
for _, name := range slices.Sorted(maps.Keys(resp.Header)) {
k := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(name), "-", "_"))
vars[k] = strings.Join(resp.Header[name], " ")
}
for k, v := range vars {
if _, ok := reservedResponseKeys[k]; ok || strings.HasPrefix(k, "duration_") {
continue
}
if _, ok := data[k]; ok {
continue
}
data[k] = v
}
}
// cookieMatchesHost reports whether a browser cookie with the given domain is
// sent to host. A host-only cookie has a domain without a leading dot and only
// matches that exact host; a domain cookie also matches its subdomains.
func cookieMatchesHost(domain, host string) bool {
host = strings.ToLower(host)
domain = strings.ToLower(domain)
if trimmed, ok := strings.CutPrefix(domain, "."); ok {
return host == trimmed || strings.HasSuffix(host, domain)
}
return host == domain
}
@@ -1,97 +0,0 @@
package engine
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
func TestAddResponseVariables(t *testing.T) {
resp := &http.Response{Header: make(http.Header)}
resp.Header.Set("Content-Type", "text/plain; charset=utf-8")
resp.Header.Add("X-Custom", "a")
resp.Header.Add("X-Custom", "b")
resp.Header.Add("Set-Cookie", "SessionID=abc; Path=/")
resp.Header.Add("Set-Cookie", "theme=dark; Path=/")
data := make(map[string]interface{})
addResponseVariables(data, resp, nil)
require.Equal(t, "text/plain; charset=utf-8", data["content_type"])
require.Equal(t, "a b", data["x_custom"])
require.Equal(t, "abc", data["sessionid"])
require.Equal(t, "dark", data["theme"])
require.NotContains(t, data, "header")
}
func TestAddResponseVariablesNil(t *testing.T) {
data := make(map[string]interface{})
addResponseVariables(data, nil, nil)
require.Empty(t, data)
}
func TestAddResponseVariablesCollisions(t *testing.T) {
resp := &http.Response{Header: make(http.Header)}
resp.Header.Set("Status-Code", "999")
resp.Header.Set("Data", "spoofed")
resp.Header.Set("Host", "evil.example")
resp.Header.Set("Duration-1", "0")
resp.Header.Set("IP", "6.6.6.6")
resp.Header.Set("Path", "/spoofed")
resp.Header.Set("Request", "spoofed")
resp.Header.Set("Error", "spoofed")
resp.Header.Set("X-Action", "from header")
resp.Header.Add("Set-Cookie", "header=spoofed; Path=/")
resp.Header.Add("Set-Cookie", "x_token=cookie; Path=/")
resp.Header.Set("X-Token", "header")
// both names normalize to x_dup; sorted order makes the result stable
resp.Header["X-Dup"] = []string{"dash"}
resp.Header["X_dup"] = []string{"underscore"}
data := map[string]interface{}{
"header": "real headers",
"status_code": "200",
"x_action": "from action",
}
addResponseVariables(data, resp, nil)
require.Equal(t, "real headers", data["header"])
require.Equal(t, "200", data["status_code"])
require.Equal(t, "from action", data["x_action"])
require.NotContains(t, data, "data")
require.NotContains(t, data, "host")
require.NotContains(t, data, "duration_1")
for _, key := range []string{"ip", "path", "request", "error"} {
require.NotContains(t, data, key)
}
require.Equal(t, "header", data["x_token"])
require.Equal(t, "underscore", data["x_dup"])
}
func TestAddResponseVariablesBrowserCookies(t *testing.T) {
resp := &http.Response{Header: make(http.Header)}
resp.Header.Add("Set-Cookie", "theme=dark; Path=/")
browserCookies := []*http.Cookie{
{Name: "SessID", Value: "abc"},
{Name: "theme", Value: "light"},
{Name: "data", Value: "spoofed"},
}
data := make(map[string]interface{})
addResponseVariables(data, resp, browserCookies)
require.Equal(t, "abc", data["sessid"])
require.Equal(t, "dark", data["theme"])
require.NotContains(t, data, "data")
}
func TestCookieMatchesHost(t *testing.T) {
require.True(t, cookieMatchesHost("example.com", "example.com"))
require.False(t, cookieMatchesHost("example.com", "sub.example.com"))
require.True(t, cookieMatchesHost(".example.com", "example.com"))
require.True(t, cookieMatchesHost(".example.com", "sub.example.com"))
require.False(t, cookieMatchesHost(".example.com", "badexample.com"))
require.False(t, cookieMatchesHost("other.com", "example.com"))
require.True(t, cookieMatchesHost("127.0.0.1", "127.0.0.1"))
}
+16 -27
View File
@@ -212,39 +212,28 @@ func (i *Instance) Run(ctx *contextargs.Context, actions []*Action, payloads map
return nil, nil, err
}
// at the end of actions pull out the cookies from the browser. They are
// exposed as DSL variables, since the browser does not report Set-Cookie
// in the intercepted response headers, and they are injected into the
// shared cookie jar unless cookie reuse is disabled. The cookies are read
// for the whole host, because a cookie scoped to another path than the
// input URL is still set by the response.
var httpCookies []*http.Cookie
if cookies, err := page.Browser().GetCookies(); err == nil {
for _, cookie := range cookies {
if !cookieMatchesHost(cookie.Domain, URL.Hostname()) {
continue
if !options.DisableCookie {
// at the end of actions pull out updated cookies from the browser and inject them into the shared cookie jar
if cookies, err := page.Cookies([]string{URL.String()}); !options.DisableCookie && err == nil && len(cookies) > 0 {
var httpCookies []*http.Cookie
for _, cookie := range cookies {
httpCookie := &http.Cookie{
Name: cookie.Name,
Value: cookie.Value,
Domain: cookie.Domain,
Path: cookie.Path,
HttpOnly: cookie.HTTPOnly,
Secure: cookie.Secure,
}
httpCookies = append(httpCookies, httpCookie)
}
httpCookie := &http.Cookie{
Name: cookie.Name,
Value: cookie.Value,
Domain: cookie.Domain,
Path: cookie.Path,
HttpOnly: cookie.HTTPOnly,
Secure: cookie.Secure,
}
httpCookies = append(httpCookies, httpCookie)
ctx.CookieJar.SetCookies(URL, httpCookies)
}
}
if !options.DisableCookie && len(httpCookies) > 0 {
ctx.CookieJar.SetCookies(URL, httpCookies)
}
if resp := createdPage.FirstResponse(); resp != nil {
headers := utils.HeadersToString(resp.Header)
data["header"] = headers
data["all_headers"] = headers
data["header"] = utils.HeadersToString(resp.Header)
data["status_code"] = fmt.Sprint(resp.StatusCode)
addResponseVariables(data, resp, httpCookies)
}
successfulPageCreation = true // avoid closing the page in case of success in deferred function
@@ -5,7 +5,6 @@ import (
"fmt"
"io"
"math/rand"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
@@ -16,7 +15,6 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -321,58 +319,6 @@ func TestActionHeadersChange(t *testing.T) {
})
}
// Chrome keeps malformed percent-escapes such as "100%.png" in request paths,
// as the WHATWG URL Standard requires, but net/url rejects them, so go-rod
// hands the modification-rules hijack handler a request with a nil URL (#8015).
//
//nolint:exhaustruct,exhaustruct_v5,paralleltest // like the other action tests, which share browser and dialer state
func TestRoutingRuleHandlerFailsRequestNetHTTPCannotParse(t *testing.T) {
for _, disableCookie := range []bool{false, true} {
t.Run(fmt.Sprintf("DisableCookie=%t", disableCookie), func(t *testing.T) {
// Count connections, not requests: net/http rejects the request line
// before any handler runs. TLS makes the headless proxy tunnel the
// request with CONNECT instead of rejecting the unparsable path itself.
var imageConns atomic.Int32
imageServer := httptest.NewUnstartedServer(http.NotFoundHandler())
imageServer.Config.ConnState = func(_ net.Conn, state http.ConnState) {
if state == http.StateNew {
imageConns.Add(1)
}
}
imageServer.StartTLS()
defer imageServer.Close()
actions := []*Action{
{
ActionType: ActionTypeHolder{ActionType: ActionSetHeader},
Data: map[string]string{"part": "request", "key": "Test", "value": "Hello"},
},
{ActionType: ActionTypeHolder{ActionType: ActionNavigate}, Data: map[string]string{"url": "{{BaseURL}}"}},
{ActionType: ActionTypeHolder{ActionType: ActionWaitLoad}},
}
handler := func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Test") == "Hello" {
_, _ = fmt.Fprintf(w, `<html><body>found<img src="%s/100%%.png"></body></html>`, imageServer.URL)
}
}
options := &Options{Timeout: 20 * time.Second, DisableCookie: disableCookie}
testHeadlessWithOptions(t, actions, options, handler, func(page *Page, err error, _ ActionData) {
require.NoError(t, err)
body := page.Page().MustElement("body").MustText()
require.Equal(t, "found", strings.TrimSpace(body), "modification rules were not applied")
require.Zero(t, imageConns.Load(), "unparsable request reached the image server")
imageDone := page.Page().MustEval(`() => document.querySelector("img").complete`).Bool()
require.True(t, imageDone, "unparsable request is still paused")
})
})
}
}
func TestActionScreenshot(t *testing.T) {
response := `
<html>
@@ -1057,21 +1003,6 @@ func TestMain(m *testing.M) {
func testHeadless(t *testing.T, actions []*Action, timeout time.Duration, handler func(w http.ResponseWriter, r *http.Request), assert func(page *Page, pageErr error, extractedData ActionData)) {
t.Helper()
options := &Options{Timeout: timeout} //nolint:exhaustruct,exhaustruct_v5 // zero values are the defaults
testHeadlessWithOptions(t, actions, options, handler, assert)
}
// testHeadlessWithOptions is testHeadless with caller-chosen page options. It
// overwrites options.Options with the test scan options.
func testHeadlessWithOptions(
t *testing.T,
actions []*Action,
options *Options,
handler func(w http.ResponseWriter, r *http.Request),
assert func(page *Page, pageErr error, extractedData ActionData),
) {
t.Helper()
lfa := envutil.GetEnvOrDefault("LOCAL_FILE_ACCESS", true)
rna := envutil.GetEnvOrDefault("RESTRICTED_LOCAL_NETWORK_ACCESS", false)
@@ -1096,8 +1027,7 @@ func testHeadlessWithOptions(
input.CookieJar, err = cookiejar.New(nil)
require.Nil(t, err)
options.Options = opts // allow file access in test
extractedData, page, err := instance.Run(input, actions, nil, options)
extractedData, page, err := instance.Run(input, actions, nil, &Options{Timeout: timeout, Options: opts}) // allow file access in test
assert(page, err, extractedData)
if page != nil {
@@ -1226,22 +1156,3 @@ func TestBlockedHeadlessURLS(t *testing.T) {
}
}
}
func TestBrowserCookiesOutsideInputPath(t *testing.T) {
actions := []*Action{
{ActionType: ActionTypeHolder{ActionType: ActionNavigate}, Data: map[string]string{"url": "{{BaseURL}}"}},
{ActionType: ActionTypeHolder{ActionType: ActionWaitLoad}},
}
handler := func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "session", Value: "s1", Path: "/"})
http.SetCookie(w, &http.Cookie{Name: "token", Value: "abc", Path: "/admin"})
_, _ = fmt.Fprintln(w, `ok`)
}
testHeadless(t, actions, 20*time.Second, handler, func(page *Page, err error, out ActionData) {
require.Nil(t, err, "could not run page actions")
require.Equal(t, "s1", out["session"])
require.Equal(t, "abc", out["token"], "a cookie scoped to another path must still be exposed")
})
}
@@ -56,10 +56,7 @@ func TestHeadlessProxy_BlocksExcludedHosts(t *testing.T) {
return
}
allowedHits.Add(1)
reqBuf := make([]byte, 512)
_, _ = conn.Read(reqBuf)
_, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK"))
time.Sleep(50 * time.Millisecond)
_ = conn.Close()
}
}()
+12 -24
View File
@@ -15,21 +15,8 @@ import (
// routingRuleHandler handles proxy rule for actions related to request/response modification
func (p *Page) routingRuleHandler(httpClient *http.Client) func(ctx *rod.Hijack) {
return func(ctx *rod.Hijack) {
req := ctx.Request.Req()
// go-rod discards url.Parse errors, so a URL that Chrome accepts but
// net/url rejects, such as "/100%.png", arrives as nil. Fail the request:
// the Go client cannot send it, and continuing it in the browser would
// drop the modification rules and, with -cdp-endpoint (no headless
// proxy), the network policy's dial-time address checks.
if req.URL == nil {
gologger.Verbose().Msgf("headless: failed %s request with a URL that net/url cannot parse", req.Method)
ctx.Response.Fail(proto.NetworkErrorReasonFailed)
return
}
// usually browsers don't use chunked transfer encoding, so we set the content-length nevertheless
req.ContentLength = int64(len(ctx.Request.Body()))
ctx.Request.Req().ContentLength = int64(len(ctx.Request.Body()))
// snapshot the rules once: ExecuteActions may still be appending rules
// from another goroutine while this hijack handler runs.
rules := p.rulesSnapshot()
@@ -41,17 +28,17 @@ func (p *Page) routingRuleHandler(httpClient *http.Client) func(ctx *rod.Hijack)
switch rule.Action {
case ActionSetMethod:
rule.Do(func() {
req.Method = rule.Args["method"]
ctx.Request.Req().Method = rule.Args["method"]
})
case ActionAddHeader:
req.Header.Add(rule.Args["key"], rule.Args["value"])
ctx.Request.Req().Header.Add(rule.Args["key"], rule.Args["value"])
case ActionSetHeader:
req.Header.Set(rule.Args["key"], rule.Args["value"])
ctx.Request.Req().Header.Set(rule.Args["key"], rule.Args["value"])
case ActionDeleteHeader:
req.Header.Del(rule.Args["key"])
ctx.Request.Req().Header.Del(rule.Args["key"])
case ActionSetBody:
body := rule.Args["body"]
req.ContentLength = int64(len(body))
ctx.Request.Req().ContentLength = int64(len(body))
ctx.Request.SetBody(body)
}
}
@@ -59,21 +46,21 @@ func (p *Page) routingRuleHandler(httpClient *http.Client) func(ctx *rod.Hijack)
// each http request is performed via the native go http client
// we first inject the shared cookies
if !p.options.DisableCookie {
if cookies := p.ctx.CookieJar.Cookies(req.URL); len(cookies) > 0 {
httpClient.Jar.SetCookies(req.URL, cookies)
if cookies := p.ctx.CookieJar.Cookies(ctx.Request.URL()); len(cookies) > 0 {
httpClient.Jar.SetCookies(ctx.Request.URL(), cookies)
}
}
// perform the request
if err := ctx.LoadResponse(httpClient, true); err != nil {
gologger.Verbose().Msgf("headless: failed to load response for %s: %s", req.URL, err)
gologger.Verbose().Msgf("headless: failed to load response for %s: %s", ctx.Request.URL(), err)
}
if !p.options.DisableCookie {
// retrieve the updated cookies from the native http client and inject them into the shared cookie jar
// keeps existing one if not present
if cookies := httpClient.Jar.Cookies(req.URL); len(cookies) > 0 {
p.ctx.CookieJar.SetCookies(req.URL, cookies)
if cookies := httpClient.Jar.Cookies(ctx.Request.URL()); len(cookies) > 0 {
p.ctx.CookieJar.SetCookies(ctx.Request.URL(), cookies)
}
}
@@ -97,6 +84,7 @@ func (p *Page) routingRuleHandler(httpClient *http.Client) func(ctx *rod.Hijack)
}
// store history
req := ctx.Request.Req()
var rawReq string
if raw, err := httputil.DumpRequestOut(req, true); err == nil {
rawReq = string(raw)
@@ -1,47 +0,0 @@
package engine
import (
"errors"
"os"
"runtime"
"testing"
"github.com/go-rod/rod/lib/launcher/flags"
"github.com/stretchr/testify/require"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
)
func TestMustDisableSandbox(t *testing.T) {
require.Equal(t, runtime.GOOS == "linux" && os.Geteuid() == 0, MustDisableSandbox(), "only root on linux runs the browser without sandbox")
require.True(t, mustDisableSandbox(true, 0))
require.False(t, mustDisableSandbox(true, 1000))
require.False(t, mustDisableSandbox(false, 0))
require.False(t, mustDisableSandbox(false, 1000))
}
func TestNewChromeLauncherSandboxFlag(t *testing.T) {
options := &types.Options{}
sandboxed, err := newChromeLauncher(options, t.TempDir(), "127.0.0.1:1", false)
require.NoError(t, err)
require.False(t, sandboxed.Has(flags.NoSandbox), "the sandbox must stay on, also inside containers")
unsandboxed, err := newChromeLauncher(options, t.TempDir(), "127.0.0.1:1", true)
require.NoError(t, err)
require.True(t, unsandboxed.Has(flags.NoSandbox))
options.HeadlessOptionalArguments = []string{"no-sandbox=true"}
override, err := newChromeLauncher(options, t.TempDir(), "127.0.0.1:1", false)
require.NoError(t, err)
require.True(t, override.Has(flags.NoSandbox), "-headless-options must still disable the sandbox")
}
func TestIsSandboxLaunchError(t *testing.T) {
err := errors.New("[launcher] Failed to get the debug url: \n[1:1:FATAL:zygote_host_impl_linux.cc(128)] No usable sandbox! If you are running on Ubuntu 23.10+ ...")
require.True(t, isSandboxLaunchError(err))
require.Equal(t, "[1:1:FATAL:zygote_host_impl_linux.cc(128)] No usable sandbox! If you are running on Ubuntu 23.10+ ...", sandboxLine(err.Error()))
require.False(t, isSandboxLaunchError(errors.New("the chrome browser is not installed")))
require.False(t, isSandboxLaunchError(nil))
}
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more