Compare commits

..
Author SHA1 Message Date
Mzack9999 f39605f83e chore: refresh security context 2026-11-07 10:29:45 +00:00
Muhammad Munib NawazandMzack9999 8cda906559 feat(js): add MQTT JavaScript library (#8075)
* feat(js): add mqtt javascript library

* fix(mqtt): address CodeRabbit feedback on security, limits, and robustness

* fix(mqtt): handle in-flight publish in ping, password without username, and staticcheck

* feat(mqtt): add InsecureSkipVerify option to ConnectOptions

* fix subscribe

* fix lint

* require tls12

---------

Co-authored-by: Mzack9999 <[email protected]>
2026-11-07 00:12:10 +01:00
KVR VishnuandMzack9999 a2e403a1d8 fix(core): avoid nested workflow deadlock at template concurrency limit (#8076)
* fix(core): avoid nested workflow deadlock at template concurrency limit

Release a workflow step's AdaptiveWaitGroup slot before acquiring slots
for its children. Matching nested parents previously held a slot while
blocked on AddWithContext, so when their count reached TemplateThreads
the workflow hung forever.

Fixes #8074

* fix workflow

---------

Co-authored-by: Mzack9999 <[email protected]>
2026-11-06 23:44:28 +01:00
Mzack9999 bbebeba8b2 Merge pull request #7974 from TassioSales/fix/json-output-nested-metadata-maps
fix(output): sanitize nested metadata maps before JSON encoding (#7971)
2026-11-06 17:56:10 +01:00
Mzack9999 578d00829a Merge pull request #8071 from projectdiscovery/dwisiswant0/fix/js/make-sshclient-close-a-no-op-without-a-connection
fix(js): isolate JS objects and handle missing connections
2026-11-06 17:56:03 +01:00
Mzack9999 3be47b098f Merge pull request #8072 from AdallomRoy/dev
Upgrade github.com/alecthomas/chroma to latest
2026-11-06 17:34:41 +01:00
Mzack9999 b29ecc6907 Merge pull request #8050 from antcybersec/fix/matcher-status-keeps-response
fix(http): keep the response in -ms failure events
2026-11-06 17:34:33 +01:00
Mzack9999 29559cbd2a Merge pull request #8052 from antcybersec/fix/header-values-keep-hash
fix(cmd): keep "#" in -H header values
2026-11-06 17:34:26 +01:00
Roy Reznik 94f7148b7e Upgrade github.com/alecthomas/chroma to latest 2026-11-06 11:47:53 +00:00
Dwi Siswanto 1829fca65c fix(js): make NetConn safe to use without a connection
NetConn had the same flaw as SSHClient. new net.NetConn() creates an
object with no connection, and every method dereferenced it, so Close,
Send and Recv all aborted the template with a nil pointer panic. Close
also kept the closed connection.

Close now does nothing when no connection is open, and clears the
connection after closing it. The send and receive methods return an
error that says to open the connection with net.Open or net.OpenTLS,
which goja throws as a JS exception the template can catch.

Signed-off-by: Dwi Siswanto <[email protected]>
2026-11-06 09:40:26 +07:00
Dwi Siswanto accfe3551b fix(js): make SSHClient.Close a no-op without a connection
Calling Close on an SSHClient that never connected, or whose Connect
failed, dereferenced a nil *ssh.Client. goja turned the panic into a
request error, so the template stopped at Close and returned nothing.

Close also kept the closed client. On a client reused across login
attempts, a later Close then failed with "use of closed network
connection", and Run reported a transport error instead of "no
connection".

Close now returns true when no connection is open, and clears the
connection after closing it. It returns true rather than an error
because goja throws a returned error as a JS exception, which would
still abort the template.

Fixes: #8070
Signed-off-by: Dwi Siswanto <[email protected]>
2026-11-06 09:40:26 +07:00
Dwi Siswanto d6ec8407fb fix(js): stop JS class constructors sharing one object
GetClassConstructor handed the same Go value to every object it
constructed, in every runtime. All generated JS classes use it, so
every new ssh.SSHClient() in a scan was one client: one script's
Connect or Close changed the connection that scripts on other targets
were using, and those writes raced.

Each constructed object now starts as a shallow copy of the instance
passed in. The generated bindings all pass a zero value, so every new
object starts empty.

Signed-off-by: Dwi Siswanto <[email protected]>
2026-11-06 09:05:43 +07:00
Mzack9999 bb2da9bbea Use only template values for postgres conns (#7985)
* isolate credentials

* pin options
2026-11-06 03:45:59 +07:00
Mzack9999 6e2b59827c keep template screenshots in a nuclei dir (#8068)
* screenshot limits

* named path

* close comment
2026-11-06 03:30:17 +07:00
dw1 875b229313 fix(http): read annotations only before the request line (#8062)
Raw requests applied @Host, @tls-sni and @timeout from any matching
line of the rendered request, headers and body included. A response
value reused in a later request could carry an "@Host:" line and send
that request, with its body, to a host the template does not name.

The raw parser and the docs already define annotations as the "@"
lines before the request line. raw.SplitAnnotations now holds that
rule once, and the parser, the request annotations, @once and the
compile-time @timeout check all read from it.

Fixes: #8061

Signed-off-by: Dwi Siswanto <[email protected]>
2026-11-06 03:29:42 +07:00
Mzack9999 91f8bd1e4f send rdap requests through the scan dialer (#8066)
* rdap dialer

* assert redirect

* ipv6 literal
2026-11-06 03:29:04 +07:00
dw1 fb0eed5122 fix(http): flag truncated responses at the effective read limit (#8046)
The "(Truncated)" marker on dumped responses rebuilt the read-limit
rule from the template max-size and -rsr instead of using the limit
the body was read with. With neither set, the body is cut at the
10 MB default, but the dump was not marked truncated.

Comparing the body length with the effective limit keeps one rule for
both the read and the marker. c918d010c already made -rsr a ceiling
over a smaller template max-size; no release contains it yet, which
is why v3.8.0 still reads the full -rsr limit per response.

Fixes: #8045
Reported-by: @Lidar-Baruch

Signed-off-by: Dwi Siswanto <[email protected]>
2026-11-05 18:07:29 +07:00
Mzack9999 29833da25a send template doh queries through the scan dialer (#8064) 2026-11-05 18:06:41 +07:00
dw1 9b519d2264 fix(http): render self-contained raw requests once (#8059)
A self-contained raw request was rendered twice: once to read the URL
from its request line, and again with the variables that URL provides,
such as {{Hostname}}. The second pass evaluated values that the first
pass had inserted. A response body of {{secret}} reused in a later
request was sent as the template variable's value, and with -ev a
response body naming an environment variable was sent as its value.

Render the request line first and check its host. Then evaluate again
only the template variables and payloads whose template text names a
URL variable, and render the annotations and the rest of the request
once. Response data is now sent unchanged, as in templates that are
not self-contained. A value that changes which line is the request
line is rejected, so the host checked against the network policy is
the host that is sent.

Fixes: #8058

Signed-off-by: Dwi Siswanto <[email protected]>
2026-11-05 18:00:36 +07:00
dw1 4fed40f75a fix(fuzz): stop re-rendering evaluated template variables (#8057)
Fuzz rules rendered the template's variables once against request
data, then passed the output to the Interactsh render pass as template
text. A variable such as b: "{{body}}" therefore had the request body
substituted into it and was rendered a second time, so DSL-shaped bytes
in the incoming request were evaluated and the result was sent to the
target. With payloads, the same leak depended on map iteration order,
because that pass rendered each value against the map it was writing
to.

Treat rendered variable output as data, as the HTTP request builder
does, so only generator payload definitions remain template text.
Without payloads nothing is left to render, so the second pass is
dropped. Payload values now render against the unrendered input, which
makes the result independent of map iteration order. A variable that
references a payload now gets the payload's unrendered definition; it
only saw the rendered value through the second pass.

Fixes: #8054

Signed-off-by: Dwi Siswanto <[email protected]>
2026-11-05 18:00:12 +07:00
dw1 2f4ac3d9e5 fix(expressions): evaluate template expressions in place (#8056)
Evaluate substituted every placeholder into the whole text first, then
wrote each expression result over the first matching marker it found.
When a substituted value repeated an expression's text, such as a
response body of {{to_upper(seed)}}, the result went into that value
and the template's own expression was left unevaluated.

Replace had the same flaw in another form: its § pass ran over the
output of its {{}} pass, so a substituted value of §name§ resolved to
the value of name, and a value could pull in any other variable.

Both now make one left-to-right pass over the template text. Evaluate
locates each expression before anything is substituted and writes the
result at that position, and Replace never scans a value it inserted.
Replace also stops converting every value up front, so rendering is
faster and allocates less.

Fixes: #8053
Fixes: #8055
Refs: #7221

Signed-off-by: Dwi Siswanto <[email protected]>
2026-11-05 17:59:43 +07:00
Mzack9999 27ac1e8726 chore(deps): bump go-jose to v4.1.6 (#8037) 2026-11-05 13:37:53 +07:00
Mzack9999 5b499e0650 verify TLS and validate template_id for nuclei -ai (#8044) 2026-11-05 13:36:59 +07:00
anant 7748411200 fix(cmd): keep "#" in -H header values
Signed-off-by: anant <[email protected]>
2026-11-05 09:59:47 +05:30
anant 0642f05241 fix(http): keep the response in -ms failure events
Signed-off-by: anant <[email protected]>
2026-11-05 09:59:35 +05:30
Mzack9999 df707d924f Verify TLS for cloud uploads (#8048) 2026-11-05 06:45:55 +07:00
Mzack9999 5f5a40c2d4 Merge pull request #8040 from projectdiscovery/feat/js-ipmi
add ipmi javascript library
2026-11-04 22:12:54 +01:00
Mzack9999 82d5a8acc2 exporters: verify TLS certificates (#8042) 2026-11-04 16:04:54 +07:00
Dogan Can Bakir 9d3be8b22e verify bmc proofs and signed replies, match replies to requests in the ipmi library 2026-11-04 10:52:52 +03:00
Dogan Can Bakir 76dd059821 add ipmi javascript library 2026-11-04 09:26:50 +03:00
Mzack9999 46fbc92a76 Merge pull request #8023 from projectdiscovery/fix/dns-resolver-client
Give each scan its own DNS resolvers
2026-11-03 21:42:45 +01:00
Mzack9999 36d0adba18 Merge pull request #8024 from projectdiscovery/fix/headless-exclude-lock
Read headless excludes under the dialer lock
2026-11-03 20:47:14 +01:00
Mzack9999 049e6b629e Merge pull request #8018 from projectdiscovery/feat/jev-llm-provider
use jev for llm matchers when -llm-provider jev
2026-11-03 19:21:59 +01:00
Dogan Can Bakir 2c6b223bb4 Merge pull request #7983 from projectdiscovery/headless-host-canonical
Deny IPv4-mapped forms of denied hosts in the dialer
2026-11-03 20:45:05 +03:00
Mzack9999 0827b2779b preserve proxy 2026-11-03 18:34:18 +01:00
Mzack9999 0971fb83e2 skip extractors 2026-11-03 18:07:38 +01:00
Mzack9999 62dae92b3d refresh resolvers 2026-11-03 17:30:04 +01:00
Mzack9999 e44df6e947 lock excludes 2026-11-03 17:30:04 +01:00
Mzack9999 25f50f1036 Merge pull request #8012 from TassioSales/fix/clean-flow-js-runtime
fix(flow): prevent global state leakage in shared JS runtime pool (#7986)
2026-11-03 17:13:36 +01:00
Mzack9999 f9446e9290 Merge pull request #8016 from projectdiscovery/dwisiswant0/fix/headless/fail-requests-whose-url-net-url-cannot-parse
fix(headless): fail requests whose URL net/url cannot parse
2026-11-03 17:12:37 +01:00
Mzack9999 faaeaf30b1 mapped deny 2026-11-03 17:07:35 +01:00
Mzack9999 b7fe335285 merge dev 2026-11-03 17:02:03 +01:00
Mzack9999 7481b20d78 Merge pull request #8014 from TassioSales/fix/headless-ipv4-mapped-ipv6-exclude-hosts-7982
fix(headless): normalize IPv4-mapped IPv6 host forms for -exclude-hosts and -lna (#7982)
2026-11-03 13:39:20 +01:00
Mzack9999 48600a24de use canonical 2026-11-03 12:51:48 +01:00
Mzack9999 ec4036147e Merge pull request #8013 from 0xABCD01/fix/govaluate-concat-regression-test
test(expressions): add regression case for govaluate concat bug (follow-up to #7998)
2026-11-03 12:51:11 +01:00
Mzack9999 9b04153a3b merge dev 2026-11-03 12:45:38 +01:00
Dogan Can Bakir 23f3407af7 bump utils for the jev provider 2026-11-03 12:50:46 +03:00
Dwi Siswanto be40690a29 fix(headless): fail requests whose URL net/url cannot parse
With a request modification action (setheader, addheader, deleteheader,
setmethod or setbody), routingRuleHandler replays every browser request
through nuclei's Go HTTP client. go-rod builds that request with
url.Parse and drops the error, so a URL that Chrome accepts but net/url
rejects, such as a path ending in "100%.png", arrived with a nil URL.
The cookie jar sync then panicked, or httputil.DumpRequestOut did when
cookies were disabled, and the panic killed the whole scan.

Check the URL once on entry and fail such requests in the browser. The
Go client cannot send them, and continuing them in the browser would
drop the template's modification rules and, with -cdp-endpoint, skip
the dial-time address checks of nuclei's network policy.

Fixes: #8015
Reported-by: @l4rm4nd
Signed-off-by: Dwi Siswanto <[email protected]>
2026-11-03 16:13:37 +07:00
Dogan Can Bakir 64f94f3e66 use jev for llm matchers when -llm-provider jev 2026-11-03 12:04:47 +03:00
TassioSales b2b54a7ac4 fix(headless): preserve port in CanonicalizeHost and stabilize proxy test
Signed-off-by: TassioSales <[email protected]>
2026-11-03 01:28:54 -03:00
TassioSales 22aedebe5d fix(headless): normalize IPv4-mapped IPv6 host forms for -exclude-hosts and -lna (#7982)
Normalize hostnames and IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1,
::ffff:7f00:1, and bracketed variants) before validating against NetworkPolicy
and ExcludeTargets in headless execution. Also expand CDP blocked patterns
in SetupHeadlessNetworkPolicy to match mapped IPv6 representations.

Signed-off-by: TassioSales <[email protected]>
2026-11-03 01:07:02 -03:00
Hafiar 5896509c84 test(expressions): add regression case for govaluate concat bug
base64(username + ':' + password) evaluated to base64 of "[admin:]admin"
on govaluate v0.0.0-20260729205521-4c61bf19382e, the pin #7998 started
from. The case was requested by @dogancanbakir in review of #7998 and did
not land before the merge.
2026-11-02 23:42:37 +00:00
Mzack9999 825dcfb10a lock runtime 2026-11-02 22:58:36 +01:00
Mzack9999 d465cc82a4 Merge pull request #7995 from KaemiaDeveloper/fix/ipv6-dual-stack-target-selection
Scan every address family with -sa
2026-11-02 22:36:42 +01:00
Mzack9999 48f36b4edc keep fallback 2026-11-02 21:47:23 +01:00
Mzack9999 0e7a56b0d7 prefer ipv6 2026-11-02 21:31:52 +01:00
TassioSales f8cf960022 fix(flow): prevent global state leakage in shared JS runtime pool (#7986)
Always replenish the JS runtime pool with a fresh, clean runtime after flow
template execution instead of reusing contaminated runtimes.

Signed-off-by: TassioSales <[email protected]>
2026-11-02 16:19:52 -03:00
Mzack9999 cbd2cb3881 Merge pull request #8010 from Ayham970/websocket-subprotocol-support
websocket: negotiate Sec-WebSocket-Protocol via the dialer
2026-11-02 19:53:04 +01:00
Mzack9999 1fc87e6b02 Merge pull request #7998 from 0xABCD01/fix/bump-panicking-deps
fix: bump govaluate and antchfx/xpath so malformed expressions error instead of panicking
2026-11-02 19:00:10 +01:00
Mzack9999 7a08c714f9 Merge pull request #8009 from projectdiscovery/dwisiswant0/fix/templates/keep-eid-exclusions-independent-of-etags
fix(templates): keep -eid exclusions independent of -etags
2026-11-02 18:59:46 +01:00
Ayham970andClaude Opus 4.8 6903f2d559 websocket: reject CR/LF in subprotocols; require negotiation in test
Address review feedback:
- Skip Sec-WebSocket-Protocol values containing CR or LF before negotiating
  them, since gobwas/ws writes subprotocols into the handshake request line and
  a line break could smuggle extra handshake headers.
- Tighten the test server to reject the upgrade unless the client offers the
  vite-hmr subprotocol, so the test fails if negotiation does not happen.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-11-02 17:22:43 +04:00
Ayham970andClaude Opus 4.8 27edbd6a64 websocket: negotiate Sec-WebSocket-Protocol via the dialer
The websocket protocol sent a Sec-WebSocket-Protocol header as a raw handshake
header but never set gobwas/ws Dialer.Protocols. gobwas validates the
server-selected subprotocol against Dialer.Protocols and rejects one that was
only sent as a header, so any server that requires and echoes a subprotocol
(e.g. a Vite HMR dev server advertising "vite-hmr") failed the handshake with
"unexpected protocol in Sec-WebSocket-Protocol header".

Route a Sec-WebSocket-Protocol request header into Dialer.Protocols (comma
splitting, trimming) instead of the raw header map, so the subprotocol is
offered in the handshake and the server's echo is accepted. Adds a regression
test with a server that only upgrades when "vite-hmr" is negotiated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-11-02 17:12:17 +04:00
Dwi Siswanto be678d9d7c fix(templates): keep -eid exclusions independent of -etags
NewTagFilter added an -eid value to the excluded IDs only when -etags
did not list the same value. The check came from the tag loops in
#1448, which changed the map it writes but not the map it reads. With
-eid dup -etags dup, the filter excluded nothing by ID. Workflow steps
still go through this filter, so a workflow ran the template that a -t
scan with the same flags excluded.

Build each filter set from its own flag with one helper, so no set
reads another flag's values. The insert-if-absent checks go too:
writing an existing map key is a no-op, and the one check that changed
anything was the wrong one. -itags stops deleting from the -etags set,
because Match already lets an included tag override its exclusion.

Fixes: #8004
Signed-off-by: Dwi Siswanto <[email protected]>
2026-11-02 19:33:26 +07:00
dw1 515b22a35a Merge pull request #8006 from projectdiscovery/dwisiswant0/fix/workflow/keep-exclusions-from-silently-emptying-workflows
fix(workflow): keep exclusions from silently emptying workflows
2026-11-02 13:13:51 +01:00
github-actions[bot]andehsandeep cd0122aa85 chore: refresh security context (#7996)
Co-authored-by: ehsandeep <[email protected]>
2026-11-02 19:09:40 +07:00
Dwi Siswanto b7d22eb52b fix(workflow): warn when a step's subtemplates cannot run
A step that loads no templates never matches, so the subtemplates
loaded below it never run. Nothing said so when an exclusion emptied
the step: a tags step dropped the filter's error, and a template step
warned only for an excluded tag, naming the template but not the
subtemplates it held back, and only with -v, since warnings are
hidden by default. Such steps are now counted in a warning shown after
loading, and -v logs each one with its workflow.

Signed-off-by: Dwi Siswanto <[email protected]>
2026-11-02 18:33:15 +07:00
Dwi Siswanto c2cecb92a8 fix(workflow): skip -es for steps whose subtemplates loaded
A step whose subtemplates loaded applied every exclusion, -es included.
Most workflows start from a detection template with info severity, so
-es info, often used to hide low-value results, left them running
nothing, while -s low,medium,high,critical, which selects the same
results, still ran them. A step with subtemplates reports no results of
its own beyond late out-of-band matches, so excluding its severity
mostly keeps the checks below it from running. Such a step now skips
-es as it skips -s.

Signed-off-by: Dwi Siswanto <[email protected]>
2026-11-02 18:33:15 +07:00
Codess 9206560d1d style(test): align regression test provider fixture 2026-11-02 12:01:06 +01:00
Codess e94840ad56 test(input): initialize exclusion map and scope DNS fixtures to test hosts 2026-11-02 11:59:33 +01:00
Dwi Siswanto aec0b6eebf fix(workflow)!: load tags steps when a subtemplate passes the filters
With a template filter set, a workflow step selected by tags loaded only
the templates that had its tags and passed the filter. When the filter
selected one of the step's subtemplates instead, the step loaded
nothing, and since subtemplates run only after their step matches, the
selected subtemplate never ran. Steps selected by template already load
for a loaded subtemplate (#7965), but the tag lookup never got that
signal, and it matched the step's tags and the filters in a single call,
so it could not skip the filters without also dropping the tags.

The tag lookup now selects templates by the step's tags first and then
filters them. A step loaded for its subtemplate used to skip every
filter that rejected a template without an error, so -eid, -es and -ept
did not apply to it while -etags and -et did. Both lookups now apply
only the exclusion filters to such a step, so every exclusion holds, as
it does against -itags since #7536.

The tag filter also let -id lift -eid for the same template, so a
workflow ran a template that a scan with the same flags skips. -eid now
wins, as in the metadata index filter.

BREAKING-CHANGE: WorkflowLoader.GetTemplatePathsByTags takes noValidate
Fixes: #7966
Refs: #7534
Signed-off-by: Dwi Siswanto <[email protected]>
2026-11-02 17:24:37 +07:00
Mzack9999 b6dd16ffbf fix concat 2026-11-02 11:23:21 +01:00
Mzack9999 d510a9c2fb Merge pull request #8003 from projectdiscovery/feat/har-input
add har input mode
2026-11-02 11:11:44 +01:00
Mzack9999 de382b5de3 Merge pull request #7994 from projectdiscovery/feat/auto-calibrate
drop HTTP matches on a host's catch-all page with -auto-calibrate
2026-11-02 11:10:23 +01:00
Mzack9999 7ed324680f Merge pull request #8000 from projectdiscovery/fix/tl-jsonl
honour -jsonl in -tl and drop the listing banner from json output
2026-11-02 11:09:12 +01:00
Dogan Can Bakir 684d55eeed add har input mode 2026-11-02 09:31:10 +03:00
Doğan Can Bakır 4dcb37ad23 honour -jsonl in -tl and drop the listing banner from json output 2026-11-02 08:23:57 +03:00
Hafiar ab506a2bb6 fix: bump govaluate and antchfx/xpath so malformed expressions error instead of panicking 2026-11-02 12:18:58 +07:00
TassioSales 6863ab16e5 fix(output): sanitize nested metadata maps before JSON encoding (#7971)
Templates containing nested maps or lists of maps in info.metadata produce yaml.v2 map[interface{}]interface{} collections during decoding. When formatting JSON output (-j / -jsonl), JSON encoders (sonic / encoding/json) fail on interface{} map keys with 'json: unsupported type: interface {}', causing Nuclei to silently drop results and report 0 matches found.

Sanitize nested metadata maps recursively to map[string]interface{} prior to JSON serialization so all matching results are written.

Fixes #7971.

Signed-off-by: TassioSales <[email protected]>
2026-11-01 22:03:25 -03:00
Codess 7b44856ce8 fix(input): avoid duplicate scans for automatic dual-stack targets 2026-11-01 21:52:20 +01:00
Codess 288f657fca fix(input): preserve IPv4 fallback when IPv6 is unreachable 2026-11-01 21:38:50 +01:00
Codess 87a8c61932 fix(input): select IPv6-only and dual-stack targets without -iv 2026-11-01 21:25:39 +01:00
Doğan Can Bakır 9c26dc1d2a document -auto-calibrate 2026-11-01 18:54:08 +03:00
Doğan Can Bakır 613e581542 calibrate against the host root and cover the http hook 2026-11-01 18:53:48 +03:00
Doğan Can Bakır 5c9f02a4fc drop http matches on a host's catch-all page with -auto-calibrate 2026-11-01 18:35:12 +03:00
ghost df57ae6d29 chore(js): update memoized functions 🤖 2026-10-30 16:11:04 +00:00
Mzack9999 2a5392eaad Merge pull request #7988 from gnuletik/fix/js-redis-reply-scan
fix(js): check redis reply headers in linear time, skip payloads
2026-10-30 17:10:36 +01:00
Mzack9999 bdf7e983c5 Merge pull request #7990 from IBazsecI/fix/openapi-swagger-nil-panics
fix(openapi,swagger): skip null spec entries instead of panicking
2026-10-30 16:58:25 +01:00
bazsec 6a3300b13d fix(openapi,swagger): drop null response headers, reject only-null servers
Address review feedback:

- swagger: a null entry under a response's `headers:` map is also
  dereferenced by openapi2conv.ToV3Response, for both operation and
  top-level responses. Drop those along with null responses.
- openapi: a `servers:` list containing only null entries now returns
  the same "no servers found" error as an empty list, instead of
  succeeding without generating any request.
2026-10-30 15:18:06 +03:00
bazsec 266593d7a0 fix(openapi,swagger): skip null spec entries instead of panicking
A null value in an OpenAPI or Swagger document (an empty list item, or a
map key with no value) decodes to a nil pointer. Several of these were
dereferenced without a check, so loading such a spec with -im openapi or
-im swagger crashed nuclei with SIGSEGV.

openapi (generator.go):
- a null entry under `servers:` -> nil *Server
- a null media type under `content:` -> nil *MediaType

swagger (dereferenced inside kin-openapi's openapi2conv.ToV3):
- a null path item under `paths:` -> nil *PathItem
- a null item under path/operation `parameters:` or a null top-level
  parameter -> nil *Parameter
- a null operation or top-level response -> nil *Response

Skip the nil entries in the generator, and drop them from the decoded
Swagger document before converting it. Other null entries are already
rejected by kin-openapi with an error and are left alone.

Also add a go-fuzz harness for the OpenAPI parser with a seed corpus, so
the weekly fuzz workflow picks it up, and regression tests that panic on
the pre-fix code.
2026-10-30 14:54:30 +03:00
gnuletikandClaude Opus 5.5 7b0ca00fdf fix(js): check redis reply headers in linear time, skip payloads
The read cap scanned a reply line by line and copied what was left of
its buffer for every line, so a 1 MiB bulk reply of newlines allocated
17 GiB. It also checked lines inside bulk payloads as if they were
headers, so a Lua script returning "a\n*abc" failed with "reply exceeds
16777216 bytes".

Follow the RESP framing as bytes pass instead: skip a bulk payload by
its declared length and check only header lines. A rejected header's
read now ends before its "\n", so go-redis never gets a line to
allocate from, and nothing is buffered or copied.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 11:56:54 +01:00
Mzack9999 2e258fc3f5 Merge pull request #7981 from kamilikamil/js-streaming-hash
Add JS modules for streaming hash and decompression
2026-10-29 22:06:48 +01:00
Mzack9999 b3596ac64e dedupe imports 2026-10-29 21:33:09 +01:00
Mzack9999 ac52212d17 canonicalize headless 2026-10-29 21:25:10 +01:00
Mzack9999 1f79f786da Merge pull request #7975 from antcybersec/fix/exclude-hosts-file-comments
fix(cmd): ignore comments in -exclude-hosts values
2026-10-29 17:17:04 +01:00
Mzack9999 3ab77ec3e7 Merge pull request #7972 from arththebird/fix/json-output-nested-yaml-maps
fix(output): encode nested template maps in JSON output
2026-10-29 16:34:01 +01:00
anant 58354693e0 fix(cmd): ignore comments in -exclude-hosts values 2026-10-29 21:02:44 +05:30
Mzack9999 dc817c8dbd Merge pull request #7970 from Eren-Akdag/fix/7969-httpx-probe-resolvers
fix(runner): use -resolvers for the httpx probe of non-URL inputs (#7969)
2026-10-29 16:30:54 +01:00
Arthur LoiselleandClaude Opus 5.5 e55509d881 fix(output): reject metadata keys that collide after string conversion
Keys of different types can format to the same string (`1` and `1.0`), and
keeping either value would depend on map iteration order. `ToJSONValue` and
`ToJSONMap` now return an error on such a collision, which both
`MarshalJSON` methods propagate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:04:42 -04:00
Mzack9999 998641728c exclude context 2026-10-29 15:57:32 +01:00
Mzack9999 4e3f78c2c0 exclude context 2026-10-29 15:57:32 +01:00
Mzack9999 7a9ec9c8da Merge pull request #7912 from gnuletik/fix/js-redis-dial-once
fix(js): dial a redis connection once and log its failures as warnings
2026-10-29 15:50:11 +01:00
Mzack9999 5d876b88e5 Merge pull request #7965 from antcybersec/fix/workflow-subtemplate-filters
fix(templates): load workflow steps only when they or a subtemplate pass the filters
2026-10-29 15:50:03 +01:00
Kamil Kamili 7d143c989a Trim comments in the nuclei/hash and nuclei/compress modules 2026-10-29 17:56:02 +04:00
Kamil Kamili aa45ef92f6 Test streamed hashes against the HTTP engine's decoded body
The streamed sha256 and mmh3 of a response read over nuclei/net must
equal sha256(body) and mmh3(body) on the body the HTTP engine's response
chain decodes, for each Content-Encoding, with and without chunked.
2026-10-29 17:29:34 +04:00
Kamil Kamili 7f57e92074 Decode chunked transfer coding in nuclei/compress
NewDecoder now takes a comma-separated list of codings in the order they
were applied, as HTTP headers list them, and accepts chunked. Finished
reports when a self-delimiting stream has ended, so a template reading a
chunked body knows when to stop calling Recv.
2026-10-29 17:29:33 +04:00
Arthur LoiselleandClaude Opus 5.5 3ae6ca8e3d fix(output): encode nested template maps in JSON output
Template YAML decodes nested maps as `map[interface{}]interface{}`, which
the JSON encoder rejects, so a result whose `info.metadata` (or javascript
args carried in `meta`) held a map was dropped from `-j`/`-jsonl` output.
`model.Info` and `output.ResultEvent` now encode their metadata with
string-keyed maps.

Closes #7971

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 09:21:08 -04:00
Kamil Kamili 6c615b76df Add nuclei/compress JS module for streaming decompression
A javascript template reading a compressed response with Recv cannot
decode it without first holding the whole body. compress.NewDecoder
takes a Content-Encoding the HTTP engine decodes (gzip, deflate, br,
zstd, or identity) and decodes the chunks written to it, handing back
up to about 1 MB per call so a highly compressed chunk never has to be
held whole.

Go's decompressors pull from a reader while templates push chunks, so
each decoder runs its decompressor on a goroutine that pauses whenever
it needs input or has 1 MB to hand back. The goroutine exits when the
script's context ends, so an abandoned decoder does not leak.
2026-10-29 16:00:39 +04:00
Kamil Kamili 681854444d Add nuclei/hash JS module for incremental hashing
A javascript template that reads a large response with Recv has to
hold the whole body to compare it, since only the DSL hash functions
exist and they take the full input. hash.New(algorithm) returns a
running hash: Write and WriteString add chunks and Sum returns the
value so far, in the same format as the DSL md5, sha1, sha256, sha512
and mmh3 functions. Memory does not grow with the data hashed.
2026-10-29 16:00:39 +04:00
Eren-Akdag 449fdd2c37 test(runner): clear proxy env in the httpx probe resolver test
The probe client uses http.ProxyFromEnvironment, so a proxy set in the
environment could route the probe away from the local test server when
the test runs on its own.
2026-10-29 14:58:05 +03:00
Eren-Akdag 9d7aa53226 fix(runner): use -resolvers for the httpx probe of non-URL inputs
The httpx client that probes non-URL inputs for http or https was built
from httpx.DefaultOptions without the configured resolvers, so the probe
resolved inputs through fastdialer's default resolvers (resolv.conf plus
1.1.1.1, 1.0.0.1, 8.8.8.8 and 8.8.4.4) even when -resolvers was set.

Pass InternalResolversList to httpxOptions.Resolvers in the runner and in
the SDK. httpx then uses only those resolvers. Without -resolvers the
probe behaves as before.

Closes #7969
2026-10-29 14:37:28 +03:00
dependabot[bot] 3ee4f0f408 chore(deps): bump github.com/getkin/kin-openapi (#7968)
Bumps the go_modules group with 1 update in the / directory: [github.com/getkin/kin-openapi](https://github.com/getkin/kin-openapi).


Updates `github.com/getkin/kin-openapi` from 0.144.0 to 0.149.0
- [Release notes](https://github.com/getkin/kin-openapi/releases)
- [Commits](https://github.com/getkin/kin-openapi/compare/v0.144.0...v0.149.0)

---
updated-dependencies:
- dependency-name: github.com/getkin/kin-openapi
  dependency-version: 0.149.0
  dependency-type: direct:production
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-29 04:37:22 +00:00
ghost 091191f518 docs: update syntax & JSON schema 🤖 2026-10-29 04:05:50 +00:00
dependabot[bot] 827f4a9f88 chore(deps): bump the modules group with 2 updates (#7942)
Bumps the modules group with 2 updates: [github.com/projectdiscovery/dsl](https://github.com/projectdiscovery/dsl) and [github.com/projectdiscovery/wappalyzergo](https://github.com/projectdiscovery/wappalyzergo).


Updates `github.com/projectdiscovery/dsl` from 0.8.24 to 0.9.0
- [Release notes](https://github.com/projectdiscovery/dsl/releases)
- [Commits](https://github.com/projectdiscovery/dsl/compare/v0.8.24...v0.9.0)

Updates `github.com/projectdiscovery/wappalyzergo` from 0.3.5 to 0.3.6
- [Release notes](https://github.com/projectdiscovery/wappalyzergo/releases)
- [Commits](https://github.com/projectdiscovery/wappalyzergo/compare/v0.3.5...v0.3.6)

---
updated-dependencies:
- dependency-name: github.com/projectdiscovery/dsl
  dependency-version: 0.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: modules
- dependency-name: github.com/projectdiscovery/wappalyzergo
  dependency-version: 0.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: modules
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-29 11:00:17 +07:00
Mzack9999 bd1c4dd226 feat(headless): expose header/cookie DSL variables (#7573)
* header variables

* Fix review

* Browser cookies

* Reserve keys

* Host cookies
2026-10-29 10:59:17 +07:00
github-actions[bot]andMzack9999 f990fe4d5d chore: refresh security context (#7693)
Co-authored-by: Mzack9999 <[email protected]>
2026-10-29 10:57:39 +07:00
Mzack9999 00ed0552da feat(js): Apply network policy to mysql ConnectWithDSN (#7768)
* enforce policy

* Fix review
2026-10-29 10:57:00 +07:00
Mzack9999 099a037928 feat(headless): chrome sandbox root-only (#7951) 2026-10-29 10:55:52 +07:00
Mzack9999 12d6f03826 test: isolate resolvers (#7952) 2026-10-29 10:53:53 +07:00
anant 1644ba7e81 fix(templates): load workflow steps only when they or a subtemplate pass the filters 2026-10-29 04:40:36 +05:30
Mzack9999 a113cf0db8 Merge pull request #7962 from je-bugshell/feat/no-redact-flag
Add -no-redact to disable the built-in credential redaction
2026-10-28 18:12:43 +01:00
Mzack9999 97337e2b4a update readmes 2026-10-28 17:41:18 +01:00
Mzack9999 8d29c12eef fix redaction 2026-10-28 16:36:38 +01:00
John Eric von Rosen 4fc17dc284 Keep a semicolon inside a redacted query value
Stopping a key=value match at every ; left ?password=;s3cr3t unmasked, since
the value could not start. The value is now lazy and ends only at a cookie
delimiter (; name=), &, a quote or a line break.
2026-10-28 13:09:35 +01:00
John Eric von Rosen 035fc270f9 Keep neighbouring cookies and query parameters when redacting one key
A redacted value used to run to the end of the line, so -redact tracking on
Cookie: tracking=1; session=abc masked the session cookie too. A key=value
match now stops at ; or &; a Key: header is still masked whole. The test also
covers the matched URL.
2026-10-28 13:00:32 +01:00
John Eric von Rosen 3f341f0222 Add -no-redact to disable the built-in credential redaction
Since #7469 the output writer always masks Authorization, Proxy-Authorization,
Cookie, Set-Cookie and X-Api-Key, which leaves exported findings without the
data needed to reproduce them by hand. -no-redact turns the built-in list off;
keys given with -redact are still masked.
2026-10-28 12:35:18 +01:00
Mzack9999 26ce6d2ef2 Merge pull request #7959 from arxhr007/fix/7949-validate-missing-path
fix(loader): fail -validate when a template path can't be found
2026-10-27 18:13:16 +01:00
Mzack9999 0f34b1b6b8 Merge pull request #7960 from projectdiscovery/fix/7957-exclude-hosts-match
fix exclude-hosts matching unrelated hosts at dial time
2026-10-27 17:03:18 +01:00
Doğan Can Bakır 77ba7792f3 keep subdomain and regex exclude-hosts entries 2026-10-27 18:37:21 +03:00
Doğan Can Bakır 8caa65a839 match exclude-hosts entries exactly at dial time 2026-10-27 17:58:24 +03:00
arxhr007 a4afaa41b2 fix(loader): fail -validate when a template path can't be found
`nuclei -validate -t /no/such/path` logged "Could not find template" and
then still printed "All templates validated successfully" and exited 0,
so a mistyped path in a CI step passed. ValidateTemplates ignored the
paths that GetTemplatesPath could not resolve.

Count the paths that aren't handled by NotFoundCallback and return the
validation error when there is any. Paths the callback resolves are
still accepted.

Fixes #7949
2026-10-27 19:59:58 +05:30
Mzack9999 abcdcf19a4 Merge pull request #7940 from mazzz3r/feat/info-placeholders
feat(templates): fill placeholders in info description, impact and remediation
2026-10-27 15:21:09 +01:00
Michael Rodger 9dc004926d fix(trackers): gitea reporter pagination (#7945) 2026-10-27 14:11:01 +07:00
Mzack9999 3d2f411e1d Merge pull request #7925 from projectdiscovery/dwisiswant0/fix/offlinehttp/read-status-lines-by-position-not-text-search
fix(offlinehttp): read status lines by position, not text search
2026-10-26 21:38:57 +01:00
Mzack9999 763e490f42 merge dev 2026-10-26 21:10:10 +01:00
Mzack9999 421ec70686 Merge pull request #7947 from projectdiscovery/dwisiswant0/fix/trackers/follow-x-next-page-in-gitlab-duplicate-search
fix(trackers): follow X-Next-Page in gitlab duplicate search
2026-10-26 20:47:55 +01:00
gnuletikandClaude Opus 5.5 1eeb2f3979 fix(js): log go-redis messages as gologger warnings
Rather than disabling go-redis's logger, route its messages through
gologger at warning level. nuclei's CLI then shows them with -v or -debug
only, and a program embedding nuclei receives them through its gologger
writer, instead of go-redis writing to stderr on its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:05:37 +01:00
gnuletikandClaude Opus 5.5 ea108ee04f fix(js): dial a redis connection once and keep go-redis off stderr
go-redis v9.19 retries a failed dial 5 times, 100ms apart, from a goroutine
that dials on its own context rather than the caller's. Against a filtered
port, a JS call returned at its execution deadline while the pool kept
dialing for ~25s; against a closed port, one call made 20 dials and took
1.7s. Each exhausted dial also printed "connection pool: failed to dial after
5 attempts" to stderr.

The redis clients now dial once per command attempt, as go-redis v9.11 did,
and go-redis's logger is disabled, as the mysql lib does for its driver.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:05:37 +01:00
Dwi Siswanto aac1427cdc fix(trackers): follow X-Next-Page in gitlab duplicate search
The GitLab tracker ended the duplicate search at the first page holding
fewer issues than duplicate-issue-page-size. GitLab caps per_page at
100, so with a page size above 100 every page looked like the last one.
The tracker read only the first 100 search results and opened a new
issue when the match came later.

The client library already parses X-Next-Page into Response.NextPage.
Follow it instead of guessing from the page length. This also drops the
empty-page request the old check sent when the last page was full.

Fixes: #7946
Refs: #7944
Signed-off-by: Dwi Siswanto <[email protected]>
2026-10-26 21:10:51 +07:00
mazzz3r c4552c6011 feat(templates): fill placeholders in info description, impact and remediation
Description, impact and remediation can reference values of the finding
with the usual {{...}} syntax: named extractors, payload values of the
matching request, variables, DSL helpers, and the result fields host,
port, ip, url, scheme, path, matched-at, matcher-name and
extractor-name, which win over values of the same name. Name, severity,
tags and classification stay static, since filtering, grouping and
issue trackers key on them.

MakeDefaultResultEvent renders each result's copy of the info, so every
protocol gets it and the template never changes. Each field goes
through render.Render without scan options, like request and matcher
text, so nested placeholders and helpers behave the same and network
helpers stay denied. A field whose expression fails keeps the
template's text.

When placeholders change the text, the result keeps the written info in
TemplateInfo (json:"-" bson:"-"):
- the writer runs the -redact patterns over the changed fields, as it
  does over the raw request and response;
- SARIF builds the shared rule from the written info and puts each
  finding's filled text, capped at 4096 characters, in its result
  message.

Info is filled only from a template's own event. Templates with info
placeholders are not clustered, since a cluster's shared event holds the
first template's variables and extracted values, and a global matcher
fills its info from the result fields alone.
2026-10-25 19:37:42 +00:00
Dwi Siswanto 4331cd0f7a fix(offlinehttp): read status lines by position, not text search
Passive mode added a minor version to status lines such as "HTTP/2 200
OK" by rewriting the first "HTTP/<digit> " anywhere in the raw data. In
a valid HTTP/1.1 response that text could sit in a header, the reason
phrase, or the body, which was then altered: a Content-Length body came
out truncated and a chunked one failed to decode. Only the protocol
token at the start of the response is normalized now.

When the data starts with a request, the response is now read from
where the request body ends, with the same normalization. That path
used to reject HTTP/2 status lines and read a request body as the
status line, then fall back to the last "HTTP/" in the data, which
fails whenever the response body contains one. A dump that keeps a
request's Content-Length but leaves out its body is retried right after
the headers. Two exchanges back to back now yield the first response
for HTTP/2, as they already did for HTTP/1.1.

Up to five interim 1xx responses are skipped to reach the final one,
and an interim response is returned only when no final response is
found. A response read after a request carries that request. Data that
cannot be read this way still uses the last "HTTP/" search; httpx
-store-chain output, which puts a newline after the first request,
relies on it to return the chain's final response.

This supersedes #7922, which anchors the version pattern and so fixes

Fixes: #7921
Fixes: #7924
Reported-by: @MasterchiefGalaxy
Signed-off-by: Dwi Siswanto <[email protected]>
2026-10-25 13:16:36 +01:00
230 changed files with 13327 additions and 5905 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) []string
GetTemplatePathsByTags(tags []string, noValidate bool) []string
GetTemplatePaths(templatesList []string, noValidate bool) []string
}
```
+5 -6
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 4)
-iv, -ip-version string[] IP version to scan of hostname (4,6); default uses dialer fallback, -sa scans all addresses
-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, jsonl, yaml, openapi, swagger, http) (default "list")
-im, -input-mode string mode of input file (list, burp, har, 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) (default "openai")
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (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,6 +221,7 @@ 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
@@ -323,12 +324,10 @@ OPTIMIZATIONS:
-stream stream mode - start elaborating without sorting the input
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-response-cache reuse identical safe GET/HEAD responses within a scan (lossless: skips requests with auth, cookies, body or custom headers)
-nh, -no-httpx disable httpx probing for non-url input
-stp, -strict-probe skip templates whose target service is unreachable: HTTP/headless on hosts httpx could not confirm and network templates on closed ports (lossless, no raw-input fallback)
-tf, -tech-filter opt-in: fingerprint HTTP targets and skip product/macro-tagged templates that cannot match (off by default; enables scan-scoped GET cache; fail-open; no-op if no tech-bound templates)
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
-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)
+3 -5
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, jsonl, yaml, openapi, swagger, http) (default "list")
-im, -input-mode string mode of input file (list, burp, har, 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) (default "openai")
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (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,6 +219,7 @@ 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
@@ -318,10 +319,7 @@ OPTIMIZATIONS:
-stream stream mode - start elaborating without sorting the input
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-response-cache reuse identical safe GET/HEAD responses within a scan (lossless: skips requests with auth, cookies, body or custom headers)
-nh, -no-httpx disable httpx probing for non-url input
-stp, -strict-probe skip templates whose target service is unreachable: HTTP/headless on hosts httpx could not confirm and network templates on closed ports (lossless, no raw-input fallback)
-tf, -tech-filter opt-in: fingerprint HTTP targets and skip product/macro-tagged templates that cannot match (off by default; enables scan-scoped GET cache; fail-open; no-op if no tech-bound templates)
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
+3 -5
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, jsonl, yaml, openapi, swagger, http) (default "list")
-im, -input-mode string mode of input file (list, burp, har, 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) (default "openai")
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (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,6 +219,7 @@ 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
@@ -318,10 +319,7 @@ OPTIMIZATIONS:
-stream stream mode - start elaborating without sorting the input
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-response-cache reuse identical safe GET/HEAD responses within a scan (lossless: skips requests with auth, cookies, body or custom headers)
-nh, -no-httpx disable httpx probing for non-url input
-stp, -strict-probe skip templates whose target service is unreachable: HTTP/headless on hosts httpx could not confirm and network templates on closed ports (lossless, no raw-input fallback)
-tf, -tech-filter opt-in: fingerprint HTTP targets and skip product/macro-tagged templates that cannot match (off by default; enables scan-scoped GET cache; fail-open; no-op if no tech-bound templates)
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
+3 -5
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, jsonl, yaml, openapi, swagger, http) (default "list")
-im, -input-mode string mode of input file (list, burp, har, 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) (default "openai")
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (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,6 +236,7 @@ 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
@@ -338,10 +339,7 @@ OPTIMIZATIONS:
-stream stream mode - start elaborating without sorting the input
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-response-cache reuse identical safe GET/HEAD responses within a scan (lossless: skips requests with auth, cookies, body or custom headers)
-nh, -no-httpx disable httpx probing for non-url input
-stp, -strict-probe skip templates whose target service is unreachable: HTTP/headless on hosts httpx could not confirm and network templates on closed ports (lossless, no raw-input fallback)
-tf, -tech-filter opt-in: fingerprint HTTP targets and skip product/macro-tagged templates that cannot match (off by default; enables scan-scoped GET cache; fail-open; no-op if no tech-bound templates)
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
+3 -5
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, jsonl, yaml, openapi, swagger, http) (default "list")
-im, -input-mode string mode of input file (list, burp, har, 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) (default "openai")
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (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,6 +219,7 @@ 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
@@ -318,10 +319,7 @@ OPTIMIZATIONS:
-stream stream mode - start elaborating without sorting the input
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-response-cache reuse identical safe GET/HEAD responses within a scan (lossless: skips requests with auth, cookies, body or custom headers)
-nh, -no-httpx disable httpx probing for non-url input
-stp, -strict-probe skip templates whose target service is unreachable: HTTP/headless on hosts httpx could not confirm and network templates on closed ports (lossless, no raw-input fallback)
-tf, -tech-filter opt-in: fingerprint HTTP targets and skip product/macro-tagged templates that cannot match (off by default; enables scan-scoped GET cache; fail-open; no-op if no tech-bound templates)
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
+3 -5
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, jsonl, yaml, openapi, swagger, http) (default "list")
-im, -input-mode string mode of input file (list, burp, har, 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) (default "openai")
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (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,6 +219,7 @@ 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
@@ -318,10 +319,7 @@ OPTIMIZATIONS:
-stream stream mode - start elaborating without sorting the input
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-response-cache reuse identical safe GET/HEAD responses within a scan (lossless: skips requests with auth, cookies, body or custom headers)
-nh, -no-httpx disable httpx probing for non-url input
-stp, -strict-probe skip templates whose target service is unreachable: HTTP/headless on hosts httpx could not confirm and network templates on closed ports (lossless, no raw-input fallback)
-tf, -tech-filter opt-in: fingerprint HTTP targets and skip product/macro-tagged templates that cannot match (off by default; enables scan-scoped GET cache; fail-open; no-op if no tech-bound templates)
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
+3 -5
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, jsonl, yaml, openapi, swagger, http) (default "list")
-im, -input-mode string mode of input file (list, burp, har, 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) (default "openai")
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (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,6 +219,7 @@ 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
@@ -318,10 +319,7 @@ OPTIMIZATIONS:
-stream stream mode - start elaborating without sorting the input
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-response-cache reuse identical safe GET/HEAD responses within a scan (lossless: skips requests with auth, cookies, body or custom headers)
-nh, -no-httpx disable httpx probing for non-url input
-stp, -strict-probe skip templates whose target service is unreachable: HTTP/headless on hosts httpx could not confirm and network templates on closed ports (lossless, no raw-input fallback)
-tf, -tech-filter opt-in: fingerprint HTTP targets and skip product/macro-tagged templates that cannot match (off by default; enables scan-scoped GET cache; fail-open; no-op if no tech-bound templates)
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
+3 -5
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, jsonl, yaml, openapi, swagger, http) (default "list")
-im, -input-mode string mode of input file (list, burp, har, 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) (default "openai")
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (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,6 +219,7 @@ 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
@@ -318,10 +319,7 @@ OPTIMIZATIONS:
-stream stream mode - start elaborating without sorting the input
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-response-cache reuse identical safe GET/HEAD responses within a scan (lossless: skips requests with auth, cookies, body or custom headers)
-nh, -no-httpx disable httpx probing for non-url input
-stp, -strict-probe skip templates whose target service is unreachable: HTTP/headless on hosts httpx could not confirm and network templates on closed ports (lossless, no raw-input fallback)
-tf, -tech-filter opt-in: fingerprint HTTP targets and skip product/macro-tagged templates that cannot match (off by default; enables scan-scoped GET cache; fail-open; no-op if no tech-bound templates)
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
+3 -5
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, jsonl, yaml, openapi, swagger, http) (default "list")
-im, -input-mode string mode of input file (list, burp, har, 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) (default "openai")
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together, orcarouter, jev) (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,6 +219,7 @@ 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
@@ -318,10 +319,7 @@ OPTIMIZATIONS:
-stream stream mode - start elaborating without sorting the input
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-response-cache reuse identical safe GET/HEAD responses within a scan (lossless: skips requests with auth, cookies, body or custom headers)
-nh, -no-httpx disable httpx probing for non-url input
-stp, -strict-probe skip templates whose target service is unreachable: HTTP/headless on hosts httpx could not confirm and network templates on closed ports (lossless, no raw-input fallback)
-tf, -tech-filter opt-in: fingerprint HTTP targets and skip product/macro-tagged templates that cannot match (off by default; enables scan-scoped GET cache; fail-open; no-op if no tech-bound templates)
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
+111 -238
View File
@@ -1,255 +1,128 @@
<!-- Generated by securitycontext.dev. Source HEAD: 3e1e0e81f091c7817bbf7af64bb8eec6e8c2bac1. -->
<!-- Generated by securitycontext.dev. Source HEAD: 8cda90655970270b215749b42ab59ecc38669293. -->
# 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.
_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.
_Analyzed [8cda906559][c-8cda906559] on `dev` · 5234 commits, 574 security-relevant examined · generated 2026-11-07_
## Recurring Security Patterns
**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`
**12.5% of classified fixes repeat a previously fixed pattern** (2 recurring, 14 first-seen). A recurring pattern means this repository fixed a similar issue before — not that the code is currently vulnerable.
## Top Risks
### 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)
Components with the strongest concentration of past fixes for one class.
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.
Evidence: 6f2ade6a9b427c284c15a43445f9c7f055e60e5d, c35162c8ef061a96dd74b54c8f022c1a41cf5833, df55f7a2eb591a7949b5c66a9c54db54aa28f450, cf2a98b03d76327c20fb0183b7f1c849eb692c91, 96049a32fa70aef0483d4180d386a57b3c2d973a
### 2. Code Execution via Unsigned Template: `pkg/catalog/loader/loader.go, pkg/templates/workflows.go, pkg/templates/templates.go` (critical)
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.
Evidence: 9de96e4dda5a03da963b9ae6582f03ea55791a76, 1c440e755a97471c56fb0276ee8a8c4132974645, fac0b7196d2fbca56c8af8ff8e1899c111041a37, e86f38299765b82ad724fdb701557e0eaff3884d, fa56800fcca3c8c8fe8e8dec28584210ca1527de
### 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 | 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 |
| # | Class | Component | Severity | Fixes | Evidence |
|---|---|---|---|---|---|
| 1 | Code Execution | `pkg/fuzz/execute.go` | high | 2 | [4fed40f75a][c-4fed40f75a], [ccbfb12bd0][c-ccbfb12bd0] |
| 2 | SSRF | `pkg/protocols/common/protocolstate/headless.go` | high | 2 | [d43c1f547f][c-d43c1f547f], [d9dabbb4a1][c-d9dabbb4a1] |
| 3 | Arbitrary File Access | `v2/pkg/protocols/common/generators/load.go` | high | 0 (1 component gone, not counted) | [e5154d362a][c-e5154d362a] |
| 4 | Arbitrary File Access | `pkg/js/libs/mysql/mysql.go` | high | 1 | [634cecbb28][c-634cecbb28] |
| 5 | Certificate Validation Bypass | `pkg/reporting/exporters/es/elasticsearch.go` | high | 1 | [82d5a8acc2][c-82d5a8acc2] |
| 6 | Code Execution | `pkg/protocols/common/expressions/expressions.go` | high | 1 | [d221732016][c-d221732016] |
## Fixed-Issue Fingerprints
These are real security fixes mined from git history. Use them as regression checks.
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.
- **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)
**By component** (directories with the most fixes):
- `pkg/fuzz` — 2 fixes: Code Execution (2)
- `pkg/protocols/common/protocolstate` — 2 fixes: SSRF (2)
- [4fed40f75a][c-4fed40f75a] **fix(fuzz): stop re-rendering evaluated template variables (#8057)** — Code Execution, high [CWE-94] · inferred · 2026-11-05
`pkg/fuzz/execute.go` · sink `rule.evaluateVarsWithInteractsh` · fix: safe parser configuration
- [82d5a8acc2][c-82d5a8acc2] **exporters: verify TLS certificates (#8042)** — Certificate Validation Bypass, high [CWE-295] · inferred · 2026-11-04
`pkg/reporting/exporters/es/elasticsearch.go` · sink `DialTLS` · fix: cryptographic verification
- [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
## Historical Vulnerability Classes
| Class | Fixes | Verified | Most recent fix |
|---|---|---|---|
| Code Execution | 3 | 2 | [2026-11-05][c-4fed40f75a] 4fed40f75a |
| 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 |
| Sandbox Escape | 2 | 0 | [2026-06-24][c-3a8b65cb45] 3a8b65cb45 |
| Signature Verification Bypass | 2 | 2 | [2026-07-03][c-fac0b7196d] fac0b7196d |
| Certificate Validation Bypass | 1 | 0 | [2026-11-04][c-82d5a8acc2] 82d5a8acc2 |
| 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 |
## Known Advisories
### Linked to a fix commit in this history (5)
- **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-8cda906559]: https://github.com/projectdiscovery/nuclei/commit/8cda90655970270b215749b42ab59ecc38669293
[c-4fed40f75a]: https://github.com/projectdiscovery/nuclei/commit/4fed40f75a57c7c414aac62d213d772cc3f430d3
[c-ccbfb12bd0]: https://github.com/projectdiscovery/nuclei/commit/ccbfb12bd01447ba25f6e755dfb2bee677736da6
[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-82d5a8acc2]: https://github.com/projectdiscovery/nuclei/commit/82d5a8acc2c756394c7fa92eae08b41330e89d69
[c-d221732016]: https://github.com/projectdiscovery/nuclei/commit/d2217320162d5782ca7cb95bef9dda17063818f3
[c-1c0f77d28e]: https://github.com/projectdiscovery/nuclei/commit/1c0f77d28ed639a34c2036fa23c7503ffac0bf27
[c-a20aa26024]: https://github.com/projectdiscovery/nuclei/commit/a20aa260245e5db889c5a421d4a65aeb2a64bf46
[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
---
_Fixed-issue fingerprints and CVE matches are derived from real data; LLM-written prose is advisory and should be verified against the cited commits._
_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._
+20
View File
@@ -530,6 +530,16 @@ Description of the template.
You can go in-depth here on what the template actually does.
Description, impact and remediation may hold {{placeholders}} filled for
each finding: named extractors, payloads, variables and DSL helpers
(network helpers excluded), and the result fields host, port, ip, url,
scheme, path, matched-at, matcher-name and extractor-name, which win over
values of the same name. Use join(", ", name) for an extractor with
several values, and brackets for a hyphenated name inside a helper:
to_upper([matcher-name]). Unknown placeholders stay as written, and so
does a field whose expression fails. Global matcher templates match on
other templates' responses, so only the result fields fill their text.
Examples:
@@ -543,6 +553,10 @@ description: Bower is a package manager which stores package information in the
description: Subversion ALM for the enterprise before 8.8.2 allows reflected XSS at multiple locations
```
```yaml
description: Redis {{version}} on {{host}}:{{port}} accepts the default password
```
</div>
@@ -559,6 +573,8 @@ Impact of the template.
You can go in-depth here on impact of the template.
Supports the same placeholders as description.
Examples:
@@ -665,6 +681,8 @@ Remediation steps for the template.
You can go in-depth here on how to mitigate the problem found by this template.
Supports the same placeholders as description.
Examples:
@@ -3757,6 +3775,8 @@ 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
View File
@@ -20,6 +20,8 @@ extend-exclude = [
"vendor/",
# Go checksum file
"go.sum",
# Generated history. Commit SHAs are not words.
"SECURITY_CONTEXT.md",
]
[default.extend-identifiers]
+57
View File
@@ -0,0 +1,57 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/stretchr/testify/require"
)
const headerChildEnv = "NUCLEI_HEADER_CHILD_ARGS"
// -H values can contain "#", on the command line, in a header file and in the
// config file, while lines of a header file that start with "#" are skipped as
// comments.
func TestHeaderValuesKeepHash(t *testing.T) {
if args := os.Getenv(headerChildEnv); args != "" {
options = &types.Options{Logger: gologger.DefaultLogger}
os.Args = append([]string{os.Args[0]}, strings.Split(args, "\n")...)
readConfig()
fmt.Printf("headers=%s\n", strings.Join(options.CustomHeaders, "|"))
os.Exit(0)
}
root := t.TempDir()
headerFile := filepath.Join(root, "headers.txt")
require.NoError(t, os.WriteFile(headerFile, []byte("# auth headers\nX-Token: abc#def\nReferer: https://app.example.com/#/dashboard\n"), 0o600))
configFile := filepath.Join(root, "config.yaml")
require.NoError(t, os.WriteFile(configFile, []byte("header:\n - \"X-Config: cfg#value\"\n"), 0o600))
tests := []struct {
name string
args []string
want string
}{
{"cli and header file", []string{"-H", "Cookie: sid=1; color=#ff0000", "-H", headerFile}, "Cookie: sid=1; color=#ff0000|X-Token: abc#def|Referer: https://app.example.com/#/dashboard"},
{"config file", []string{"-config", configFile}, "X-Config: cfg#value"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempRoot := filepath.Join(t.TempDir(), "tmp")
require.NoError(t, os.Mkdir(tempRoot, 0o700))
args := append(tt.args, "-disable-update-check")
cmd := exec.Command(os.Args[0], "-test.run=^TestHeaderValuesKeepHash$", "-test.count=1")
cmd.Env = isolatedNucleiStorageEnv(root, tempRoot, headerChildEnv+"="+strings.Join(args, "\n"))
output, err := cmd.CombinedOutput()
require.NoError(t, err, string(output))
require.Contains(t, string(output), "headers="+tt.want+"\n")
})
}
}
+2 -1
View File
@@ -57,7 +57,8 @@
# severity: low
# # duplicate-issue-check (optional) flag to enable duplicate tracking issue check
# duplicate-issue-check: false
# # duplicate-issue-page-size (optional) controls how many issues to fetch per page when searching for duplicates
# # duplicate-issue-page-size (optional) sets the number of issues to request per page when searching for duplicates
# # (GitLab returns at most 100 issues per page)
# duplicate-issue-page-size: 100
# # duplicate-issue-max-pages (optional) limits how many pages to fetch when searching for duplicates (0 = no limit)
# duplicate-issue-max-pages: 0
+22 -7
View File
@@ -55,6 +55,17 @@ var (
inlineSecretsTempMu sync.Mutex
)
// normalizeHeaderValue trims a -H value and drops it when the whole line is a
// "#" comment. The goflags default strips inline comments, which cut values
// such as "Cookie: color=#fff" at the "#".
func normalizeHeaderValue(s string) string {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "#") {
return ""
}
return s
}
func main() {
options.Logger = gologger.DefaultLogger
@@ -255,6 +266,11 @@ func readConfig() *goflags.FlagSet {
var pdcpAuth string
var fuzzFlag bool
// FileStringSliceOptions as set up by runner.ConfigureOptions, but keeping
// "#" inside -H values
headerSliceOptions := goflags.FileStringSliceOptions
headerSliceOptions.Normalize = normalizeHeaderValue
flagSet := goflags.NewFlagSet()
flagSet.CaseSensitive = true
flagSet.SetDescription(`Nuclei is a fast, template based vulnerability scanner focusing
@@ -271,10 +287,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.FileCommaSeparatedStringSliceOptions),
flagSet.StringSliceVarP(&options.ExcludeTargets, "exclude-hosts", "eh", nil, "hosts to exclude to scan from the input list (ip, cidr, hostname)", goflags.FileNormalizedOriginalStringSliceOptions),
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 4)", goflags.CommaSeparatedStringSliceOptions),
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.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 +325,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)"),
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.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,6 +371,7 @@ 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",
@@ -366,7 +383,7 @@ on extensive configurability, massive extensibility and ease of use.`)
flagSet.IntVarP(&options.MaxRedirects, "max-redirects", "mr", 10, "max number of redirects to follow for http templates"),
flagSet.BoolVarP(&options.DisableRedirects, "disable-redirects", "dr", false, "disable redirects for http templates"),
flagSet.StringVarP(&options.ReportingConfig, "report-config", "rc", "", "nuclei reporting module configuration file"), // TODO merge into the config file or rename to issue-tracking
flagSet.StringSliceVarP(&options.CustomHeaders, "header", "H", nil, "custom header/cookie to include in all http request in header:value format (cli, file)", goflags.FileStringSliceOptions),
flagSet.StringSliceVarP(&options.CustomHeaders, "header", "H", nil, "custom header/cookie to include in all http request in header:value format (cli, file)", headerSliceOptions),
flagSet.RuntimeMapVarP(&options.Vars, "var", "V", nil, "custom vars in key=value format"),
flagSet.StringVarP(&options.ResolversFile, "resolvers", "r", "", "file containing resolver list for nuclei"),
flagSet.BoolVarP(&options.SystemResolvers, "system-resolvers", "sr", false, "use system DNS resolving as error fallback"),
@@ -466,13 +483,11 @@ on extensive configurability, massive extensibility and ease of use.`)
scanstrategy.TemplateSpray.String(): goflags.EnumVariable(2),
}),
flagSet.DurationVarP(&options.InputReadTimeout, "input-read-timeout", "irt", time.Duration(3*time.Minute), "timeout on input read"),
flagSet.BoolVar(&options.ResponseCache, "response-cache", false, "reuse identical safe GET/HEAD responses within a scan (lossless: skips requests with auth, cookies, body or custom headers)"),
flagSet.BoolVarP(&options.DisableHTTPProbe, "no-httpx", "nh", false, "disable httpx probing for non-url input"),
flagSet.BoolVarP(&options.StrictProbe, "strict-probe", "stp", false, "skip templates whose target service is unreachable: HTTP/headless on hosts httpx could not confirm and network templates on closed ports (lossless, no raw-input fallback)"),
flagSet.BoolVarP(&options.TechFilter, "tech-filter", "tf", false, "opt-in: fingerprint HTTP targets and skip product/macro-tagged templates that cannot match (off by default; enables scan-scoped GET cache; fail-open; no-op if no tech-bound templates)"),
flagSet.BoolVar(&options.PreflightPortScan, "preflight-portscan", false, "run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)"),
flagSet.BoolVar(&options.DisableStdin, "no-stdin", false, "disable stdin processing"),
flagSet.BoolVar(&options.EnableHTTPCache, "http-cache", false, "enable HTTP cache (RFC 9111) for HTTP requests"),
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",
+15 -17
View File
@@ -15,13 +15,14 @@ require (
github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d
github.com/RedTeamPentesting/adauth v0.5.4-0.20260511073005-3d18e8a5a687
github.com/adrg/xdg v0.5.3
github.com/alecthomas/chroma v0.10.0
github.com/alecthomas/chroma/v2 v2.27.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.5
github.com/antchfx/htmlquery v1.3.7
github.com/antchfx/xmlquery v1.4.4
github.com/antchfx/xpath v1.3.6
github.com/antchfx/xpath v1.3.9
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
@@ -33,10 +34,11 @@ 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.144.0
github.com/getkin/kin-openapi v0.149.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
@@ -61,6 +63,7 @@ require (
github.com/jhump/protoreflect v1.17.0
github.com/julienschmidt/httprouter v1.3.0
github.com/kitabisa/go-ci v1.0.3
github.com/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
@@ -71,11 +74,12 @@ require (
github.com/miekg/dns v1.1.73
github.com/oiweiwei/go-msrpc v1.2.12
github.com/olekukonko/tablewriter v1.0.8
github.com/openrdap/rdap v0.9.1
github.com/ory/dockertest/v3 v3.12.0
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.8.24
github.com/projectdiscovery/dsl v0.9.0
github.com/projectdiscovery/fastdialer v0.6.0
github.com/projectdiscovery/fasttemplate v0.0.2
github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c
@@ -85,7 +89,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-20260615100919-5ee2581bbf7e
github.com/projectdiscovery/govaluate v0.0.0-20261102102118-9294133b15a2
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76
github.com/projectdiscovery/hmap v0.0.102
github.com/projectdiscovery/httpx v1.12.0
@@ -102,8 +106,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.12-0.20261025172519-64b3c221cfe5
github.com/projectdiscovery/wappalyzergo v0.3.5
github.com/projectdiscovery/utils v0.11.13-0.20261103094816-5c0b20e35350
github.com/projectdiscovery/wappalyzergo v0.3.6
github.com/projectdiscovery/yamldoc-go v1.0.7
github.com/redis/go-redis/v9 v9.19.0
github.com/remeh/sizedwaitgroup v1.0.0
@@ -113,6 +117,7 @@ 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
@@ -171,11 +176,9 @@ require (
github.com/STARRY-S/zip v0.2.3 // indirect
github.com/VividCortex/ewma v1.2.0 // indirect
github.com/akrylysov/pogreb v0.10.2 // indirect
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
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
@@ -234,12 +237,10 @@ require (
github.com/dimchansky/utfbom v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/djherbis/times v1.6.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
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
@@ -262,7 +263,7 @@ require (
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.6 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
@@ -323,7 +324,6 @@ 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
@@ -366,7 +366,6 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opencontainers/runc v1.3.6 // indirect
github.com/openrdap/rdap v0.9.1 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/pierrec/lz4/v4 v4.1.23 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
@@ -385,14 +384,13 @@ 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.2 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // 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
+22 -26
View File
@@ -147,15 +147,13 @@ github.com/akrylysov/pogreb v0.10.2 h1:e6PxmeyEhWyi2AKOBIJzAEi4HkiC+lKyCocRGlnDi
github.com/akrylysov/pogreb v0.10.2/go.mod h1:pNs6QmpQ1UlTJKDezuRWmaqkgUE2TuU0YTWyqJZ7+lI=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
github.com/alecthomas/kingpin/v2 v2.3.1/go.mod h1:oYL5vtsvEHZGHxU7DMp32Dvx+qL+ptGn6lWaot2vCNE=
github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY=
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
@@ -180,14 +178,13 @@ 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.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0=
github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA=
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/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.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/antchfx/xpath v1.3.9 h1:muXJSod6jRzhsiUjvduNSxsRSptFZ2GOJ1oMD1QkPSA=
github.com/antchfx/xpath v1.3.9/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=
@@ -359,7 +356,6 @@ github.com/ditashi/jsbeautifier-go v0.0.0-20141206144643-2520a8026a9c h1:+Zo5Ca9
github.com/ditashi/jsbeautifier-go v0.0.0-20141206144643-2520a8026a9c/go.mod h1:HJGU9ULdREjOcVGZVPB5s6zYmHi1RxzT71l2wQyLmnE=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
@@ -427,8 +423,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.144.0 h1:hIRcTH+KjLfkLpYU6bSSfdFpi0fZi1fp+hSPi4aQu9Y=
github.com/getkin/kin-openapi v0.144.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
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/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=
@@ -456,8 +452,8 @@ github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuw
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4=
github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
@@ -935,8 +931,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.8.24 h1:rN2w6LSIjA16qKGRjyj70oxdwXGaspe6sAjEkP7F2fk=
github.com/projectdiscovery/dsl v0.8.24/go.mod h1:V+EludvS5PS19UNMlM3CuBwZ+sFnMaQhCFGxysDUanQ=
github.com/projectdiscovery/dsl v0.9.0 h1:i1KyCbKJyKfHkgdbfh0JkWdd14APUjpEYf+GaYANlCg=
github.com/projectdiscovery/dsl v0.9.0/go.mod h1:ntNFkkiYCTEosGFTbApA61RMrvJTCjnakPxnWj56MyY=
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=
@@ -957,8 +953,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-20260615100919-5ee2581bbf7e h1:vxzgQlz2Cy/YvizYDQx9OhucBcmBotfDhbQ4yCY2vfA=
github.com/projectdiscovery/govaluate v0.0.0-20260615100919-5ee2581bbf7e/go.mod h1:xH7bPwHxUlz1yx9UlVeTF+UVCUaKhTnZgaxHb5z362E=
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/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=
@@ -999,10 +995,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.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/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/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=
@@ -1055,8 +1051,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.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
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/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=
+29 -5
View File
@@ -4,6 +4,8 @@ import (
"bufio"
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net/http"
@@ -87,11 +89,7 @@ func NewUploadWriter(ctx context.Context, logger *gologger.Logger, creds *pdcpau
tmp.Update()
u.uploadURL = tmp.URL
// create http client
opts := retryablehttp.DefaultOptionsSingle
opts.NoAdjustTimeout = true
opts.Timeout = time.Duration(3) * time.Minute
u.client = retryablehttp.NewClient(opts)
u.client = newUploadClient()
// create context
ctx, u.cancel = context.WithCancel(ctx)
@@ -101,6 +99,32 @@ func NewUploadWriter(ctx context.Context, logger *gologger.Logger, creds *pdcpau
return u, nil
}
// newUploadClient returns the client for cloud uploads. The upload sends the
// API key and the scan results, so the client verifies the server certificate.
// The retryablehttp default clients skip verification.
func newUploadClient() *retryablehttp.Client {
transport := retryablehttp.DefaultReusePooledTransport()
// fastdialer DialTLS does not verify certificates, so net/http does the
// TLS handshake over the fastdialer connection.
transport.DialTLSContext = nil
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
opts := retryablehttp.DefaultOptionsSingle
opts.NoAdjustTimeout = true
opts.Timeout = time.Duration(3) * time.Minute
opts.HttpClient = &http.Client{Transport: transport}
// a certificate error does not go away on retry
defaultRetry := retryablehttp.DefaultRetryPolicy()
opts.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) {
var certErr *tls.CertificateVerificationError
if errors.As(err, &certErr) {
return false, nil
}
return defaultRetry(ctx, resp, err)
}
return retryablehttp.NewClient(opts)
}
// SetScanID sets the scan id for the upload writer
func (u *UploadWriter) SetScanID(id string) error {
if !xidRegex.MatchString(id) {
+55
View File
@@ -0,0 +1,55 @@
package pdcp
import (
"context"
"crypto/tls"
"crypto/x509"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/projectdiscovery/gologger"
pdcpauth "github.com/projectdiscovery/utils/auth/pdcp"
"github.com/stretchr/testify/require"
)
func newTestUploadWriter(t *testing.T, gotKey *atomic.Value) (*UploadWriter, *httptest.Server) {
t.Helper()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotKey.Store(r.Header.Get(pdcpauth.ApiKeyHeaderName))
_, _ = w.Write([]byte(`{"id":"abcdefghij0123456789"}`))
}))
t.Cleanup(srv.Close)
creds := &pdcpauth.PDCPCredentials{APIKey: "test-key", Server: srv.URL}
u, err := NewUploadWriter(context.Background(), gologger.DefaultLogger, creds)
require.NoError(t, err)
t.Cleanup(u.Close)
return u, srv
}
func TestUploadRejectsUntrustedCertificate(t *testing.T) {
var gotKey atomic.Value
u, _ := newTestUploadWriter(t, &gotKey)
err := u.upload([]byte("{}\n"))
require.Error(t, err)
var unknownAuthority x509.UnknownAuthorityError
require.ErrorAs(t, err, &unknownAuthority)
require.Nil(t, gotKey.Load(), "API key must not reach a server with an untrusted certificate")
}
func TestUploadTrustedCertificate(t *testing.T) {
var gotKey atomic.Value
u, srv := newTestUploadWriter(t, &gotKey)
pool := x509.NewCertPool()
pool.AddCert(srv.Certificate())
transport, ok := u.client.HTTPClient.Transport.(*http.Transport)
require.True(t, ok)
transport.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
require.NoError(t, u.upload([]byte("{}\n")))
require.Equal(t, "test-key", gotKey.Load())
require.Equal(t, "abcdefghij0123456789", u.scanID)
}
-445
View File
@@ -1,445 +0,0 @@
package runner
import (
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/projectdiscovery/httpx/common/httpx"
"github.com/projectdiscovery/nuclei/v3/pkg/input"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/projectdiscovery/nuclei/v3/pkg/utils"
syncutil "github.com/projectdiscovery/utils/sync"
)
const hostGroupProbeWorkers = 100
// hostGroup is a set of targets that share the same open-port / HTTP signature.
// Used for savings estimates / logging; execution itself is a single spray pass
// with a per-(template,target) filter derived from the same probe data.
type hostGroup struct {
key string
hosts []*contextargs.MetaInput
openPorts map[string]struct{}
httpOK bool
explicitPort string // operator host:port; network dials this, not template defaults
}
type hostProbeResult struct {
input *contextargs.MetaInput
openPorts []string
httpOK bool
explicitPort string
}
// hostReachabilityIndex maps each input to its probed reachability so the
// engine can skip impossible template×target pairs in a single Execute pass.
type hostReachabilityIndex struct {
byInput map[string]hostReachability
}
type hostReachability struct {
openPorts map[string]struct{}
httpOK bool
// explicitPort is the operator-specified port from host:port input (if any).
// Network execution keeps this port (UseNetworkPort does not replace bare
// host:port), so reachability must not require the template's default port.
explicitPort string
}
// Allow reports whether template may produce a finding on input (lossless).
func (idx *hostReachabilityIndex) Allow(t *templates.Template, mi *contextargs.MetaInput) bool {
if idx == nil || t == nil || mi == nil {
return true
}
h, ok := idx.byInput[mi.Input]
if !ok {
return true
}
return templateAllowedOnHost(t, h.httpOK, h.openPorts, h.explicitPort)
}
func templateAllowedOnHost(t *templates.Template, httpOK bool, openPorts map[string]struct{}, explicitPort string) bool {
if t.SelfContained || len(t.Workflows) > 0 || isUniversalTemplate(t) {
return true
}
if isWebTemplate(t) {
return httpOK
}
ports, ok := tcpNetworkOnlyPorts(t)
if !ok {
return true
}
// host:port inputs: dial the operator-chosen port at execution time, not the
// template default (see contextargs.UseNetworkPort). If that port is
// reachable, network templates may run against it.
if explicitPort != "" {
if _, open := openPorts[explicitPort]; open {
return true
}
return false
}
for _, p := range ports {
if _, open := openPorts[p]; open {
return true
}
}
return false
}
// estimateTemplateRequests mirrors core.getRequestCount for scheduling math.
func estimateTemplateRequests(tpls []*templates.Template) int {
count := 0
for _, t := range tpls {
if len(t.Workflows) > 0 {
continue
}
if t.TotalRequests > 0 {
count += t.TotalRequests
continue
}
count++
}
return count
}
func hostGroupKey(openPorts []string, httpOK bool) string {
ports := append([]string(nil), openPorts...)
sort.Strings(ports)
http := "nohttp"
if httpOK {
http = "http"
}
if len(ports) == 0 {
return http + "|-"
}
return http + "|" + strings.Join(ports, ",")
}
func isWebTemplate(t *templates.Template) bool {
return len(t.RequestsHTTP)+len(t.RequestsWithHTTP)+len(t.RequestsHeadless)+len(t.RequestsWebsocket) > 0 &&
len(t.RequestsNetwork)+len(t.RequestsWithTCP)+len(t.RequestsDNS)+len(t.RequestsFile)+
len(t.RequestsSSL)+len(t.RequestsCode)+len(t.RequestsJavascript)+len(t.Workflows) == 0
}
func isUniversalTemplate(t *templates.Template) bool {
if t.SelfContained || len(t.Workflows) > 0 {
return true
}
if _, ok := tcpNetworkOnlyPorts(t); ok {
return false
}
if isWebTemplate(t) {
return false
}
return true
}
// templatesForHostGroup returns templates that can produce findings on this group.
func templatesForHostGroup(all []*templates.Template, g hostGroup) []*templates.Template {
out := make([]*templates.Template, 0, len(all))
for _, t := range all {
if t.SelfContained || len(t.Workflows) > 0 || isUniversalTemplate(t) {
continue
}
if templateAllowedOnHost(t, g.httpOK, g.openPorts, g.explicitPort) {
out = append(out, t)
}
}
return out
}
func partitionTemplates(all []*templates.Template) (web, network, universal, selfContained []*templates.Template) {
for _, t := range all {
switch {
case t.SelfContained:
selfContained = append(selfContained, t)
case len(t.Workflows) > 0:
universal = append(universal, t)
case isWebTemplate(t):
web = append(web, t)
case isUniversalTemplate(t):
universal = append(universal, t)
default:
network = append(network, t)
}
}
return
}
// buildHostReachability probes each target once, then returns an index for
// single-pass filtering plus host groups for logging/estimates.
//
// HTTP reachability prefers the already-populated InputHelper.InputsHTTP map
// (from initializeTemplatesHTTPInput) so we do not re-run httpx.
// When probePorts is false, only explicit input ports are recorded (HTTP-only mode).
func (r *Runner) buildHostReachability(tpls []*templates.Template, httpHelper *input.Helper, probePorts bool) (*hostReachabilityIndex, []hostGroup, error) {
if r.inputProvider == nil {
return nil, nil, fmt.Errorf("no input provider")
}
var portsToProbe []string
if probePorts {
// Only concrete network-template ports. Do not pull 80/443 from HTTP
// templates via portsPopularityFromTemplates — httpOK already comes from
// scheme / InputsHTTP, and spraying web ports across every host inflates
// probe cost and flakes under parallel dial load.
seen := map[string]struct{}{}
for _, t := range tpls {
ps, ok := tcpNetworkOnlyPorts(t)
if !ok {
continue
}
for _, p := range ps {
if _, dup := seen[p]; dup {
continue
}
seen[p] = struct{}{}
portsToProbe = append(portsToProbe, p)
}
}
sort.Strings(portsToProbe)
}
var targets []*contextargs.MetaInput
r.inputProvider.Iterate(func(mi *contextargs.MetaInput) bool {
targets = append(targets, mi.Clone())
return true
})
if len(targets) == 0 {
return &hostReachabilityIndex{byInput: map[string]hostReachability{}}, nil, nil
}
// Only build an httpx client if we still need live HTTP probes.
needLiveHTTPProbe := false
for _, mi := range targets {
if strings.HasPrefix(mi.Input, "http://") || strings.HasPrefix(mi.Input, "https://") {
continue
}
if httpHelper != nil && httpHelper.InputsHTTPProbed {
continue // already probed (hit or miss) during initializeTemplatesHTTPInput
}
needLiveHTTPProbe = true
break
}
var httpClient *httpx.HTTPX
if needLiveHTTPProbe {
dialers := protocolstate.GetDialersWithId(r.options.ExecutionId)
if dialers != nil {
httpxOptions := httpx.DefaultOptions
if r.options.AliveHttpProxy != "" {
httpxOptions.Proxy = r.options.AliveHttpProxy
} else if r.options.AliveSocksProxy != "" {
httpxOptions.Proxy = r.options.AliveSocksProxy
}
httpxOptions.RetryMax = r.options.Retries
if r.options.Timeout > 0 {
httpxOptions.Timeout = time.Duration(r.options.Timeout) * time.Second
}
httpxOptions.NetworkPolicy = dialers.NetworkPolicy
if c, err := httpx.New(&httpxOptions); err == nil {
httpClient = c
}
}
}
workers := hostGroupProbeWorkers
// Fan out one task per host:port so closed-port timeouts do not serialize per host.
type probeJob struct {
hostIdx int
host string
port string
}
var jobs []probeJob
hostMeta := make([]struct {
mi *contextargs.MetaInput
host string
explicitPort string
}, len(targets))
for i, mi := range targets {
host, explicitPort := hostAndExplicitPort(mi.Target())
if host == "" {
host, explicitPort = hostAndExplicitPort(mi.Input)
}
hostMeta[i].mi = mi
hostMeta[i].host = host
hostMeta[i].explicitPort = explicitPort
if !probePorts {
continue
}
// Always probe the operator-specified port (may differ from template
// defaults). Do not spray unrelated template ports onto host:port inputs.
if explicitPort != "" && isNumericPort(explicitPort) {
jobs = append(jobs, probeJob{hostIdx: i, host: host, port: explicitPort})
continue
}
for _, p := range portsToProbe {
jobs = append(jobs, probeJob{hostIdx: i, host: host, port: p})
}
}
if len(jobs) < workers {
workers = len(jobs)
}
if workers < 1 {
workers = 1
}
openByHost := make([]map[string]struct{}, len(targets))
for i := range openByHost {
openByHost[i] = map[string]struct{}{}
// Only assume explicit ports are open when we are not probing them.
if !probePorts {
if ep := hostMeta[i].explicitPort; ep != "" && isNumericPort(ep) {
openByHost[i][ep] = struct{}{}
}
}
}
if len(jobs) > 0 {
swg, err := syncutil.New(syncutil.WithSize(workers))
if err != nil {
return nil, nil, err
}
var mu sync.Mutex
for _, job := range jobs {
swg.Add()
go func(job probeJob) {
defer swg.Done()
switch r.probe(job.host, job.port) {
case portOpen, portUnknown:
mu.Lock()
openByHost[job.hostIdx][job.port] = struct{}{}
mu.Unlock()
}
}(job)
}
swg.Wait()
}
results := make([]hostProbeResult, len(targets))
for i, meta := range hostMeta {
open := openByHost[i]
ports := make([]string, 0, len(open))
for p := range open {
ports = append(ports, p)
}
sort.Strings(ports)
results[i] = hostProbeResult{
input: meta.mi,
openPorts: ports,
httpOK: resolveHTTPReachability(meta.mi, open, httpHelper, httpClient),
explicitPort: meta.explicitPort,
}
}
idx := &hostReachabilityIndex{byInput: make(map[string]hostReachability, len(results))}
grouped := map[string]*hostGroup{}
order := make([]string, 0)
for _, res := range results {
if res.input == nil {
continue
}
portsSet := make(map[string]struct{}, len(res.openPorts))
for _, p := range res.openPorts {
portsSet[p] = struct{}{}
}
idx.byInput[res.input.Input] = hostReachability{
openPorts: portsSet,
httpOK: res.httpOK,
explicitPort: res.explicitPort,
}
key := hostGroupKey(res.openPorts, res.httpOK)
if res.explicitPort != "" {
// Keep explicit host:port inputs in their own estimate bucket so
// network-template fan-out is not attributed to bare hosts.
key = key + "|ep:" + res.explicitPort
}
g, ok := grouped[key]
if !ok {
g = &hostGroup{
key: key,
openPorts: portsSet,
httpOK: res.httpOK,
explicitPort: res.explicitPort,
}
grouped[key] = g
order = append(order, key)
}
g.hosts = append(g.hosts, res.input)
}
out := make([]hostGroup, 0, len(order))
for _, key := range order {
out = append(out, *grouped[key])
}
return idx, out, nil
}
func resolveHTTPReachability(mi *contextargs.MetaInput, open map[string]struct{}, helper *input.Helper, client *httpx.HTTPX) bool {
if mi == nil {
return false
}
if strings.HasPrefix(mi.Input, "http://") || strings.HasPrefix(mi.Input, "https://") {
return true
}
// Reuse httpx results from initializeTemplatesHTTPInput when available.
if helper != nil && helper.InputsHTTP != nil {
if probed, ok := helper.InputsHTTP.Get(mi.Input); ok && len(probed) > 0 {
return true
}
// Empty/skipped probe map (e.g. MultiFormat) must not force non-HTTP.
if !helper.InputsHTTPProbed {
// fall through to live probe / open ports
} else {
// Map was built for all inputs: absence means not HTTP.
return false
}
}
if client != nil {
return utils.ProbeURL(mi.Input, client) != ""
}
if _, ok := open["80"]; ok {
return true
}
if _, ok := open["443"]; ok {
return true
}
return false
}
// estimateGroupedExecutions returns baseline and filtered template×host counts.
func estimateGroupedExecutions(all []*templates.Template, groups []hostGroup, hostCount int) (baseline, filtered int) {
baseline = estimateTemplateRequests(all) * hostCount
_, _, universal, selfContained := partitionTemplates(all)
once := append([]*templates.Template{}, selfContained...)
once = append(once, universal...)
filtered = estimateTemplateRequests(once) * hostCount
for _, g := range groups {
filtered += estimateTemplateRequests(templatesForHostGroup(all, g)) * len(g.hosts)
}
return baseline, filtered
}
// countReachabilityStats returns concrete network template count and distinct numeric ports to probe.
func countReachabilityStats(tpls []*templates.Template) (concreteNetwork int, portsToProbe int) {
ports := map[string]struct{}{}
for _, t := range tpls {
ps, ok := tcpNetworkOnlyPorts(t)
if !ok {
continue
}
concreteNetwork++
for _, p := range ps {
ports[p] = struct{}{}
}
}
return concreteNetwork, len(ports)
}
-217
View File
@@ -1,217 +0,0 @@
package runner
import (
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/input"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/network"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/stretchr/testify/require"
)
func TestHostGroupKey(t *testing.T) {
// Lexicographic sort: "443" < "80".
require.Equal(t, "http|443,80", hostGroupKey([]string{"443", "80"}, true))
require.Equal(t, "nohttp|22", hostGroupKey([]string{"22"}, false))
require.Equal(t, "nohttp|-", hostGroupKey(nil, false))
}
func TestTemplatesForHostGroup(t *testing.T) {
web := &templates.Template{
ID: "web",
RequestsHTTP: []*http.Request{{}},
}
ftp := &templates.Template{
ID: "ftp",
RequestsNetwork: []*network.Request{{
Port: "21",
}},
}
ssh := &templates.Template{
ID: "ssh",
RequestsNetwork: []*network.Request{{
Port: "22",
}},
}
all := []*templates.Template{web, ftp, ssh}
webGroup := hostGroup{
key: "http|80,443",
httpOK: true,
openPorts: map[string]struct{}{"80": {}, "443": {}},
}
got := templatesForHostGroup(all, webGroup)
require.Len(t, got, 1)
require.Equal(t, "web", got[0].ID)
ftpGroup := hostGroup{
key: "nohttp|21",
httpOK: false,
openPorts: map[string]struct{}{"21": {}},
}
got = templatesForHostGroup(all, ftpGroup)
require.Len(t, got, 1)
require.Equal(t, "ftp", got[0].ID)
sshGroup := hostGroup{
key: "nohttp|22",
httpOK: false,
openPorts: map[string]struct{}{"22": {}},
}
got = templatesForHostGroup(all, sshGroup)
require.Len(t, got, 1)
require.Equal(t, "ssh", got[0].ID)
}
func TestPartitionTemplates(t *testing.T) {
web := &templates.Template{ID: "web", RequestsHTTP: []*http.Request{{}}}
ftp := &templates.Template{ID: "ftp", RequestsNetwork: []*network.Request{{Port: "21"}}}
self := &templates.Template{ID: "self", SelfContained: true}
dynNet := &templates.Template{
ID: "dyn",
RequestsNetwork: []*network.Request{{Port: ""}},
}
w, n, u, s := partitionTemplates([]*templates.Template{web, ftp, self, dynNet})
require.Len(t, w, 1)
require.Equal(t, "web", w[0].ID)
require.Len(t, n, 1)
require.Equal(t, "ftp", n[0].ID)
require.Len(t, u, 1)
require.Equal(t, "dyn", u[0].ID)
require.Len(t, s, 1)
require.Equal(t, "self", s[0].ID)
}
func TestEstimateGroupedVsBaseline(t *testing.T) {
web := &templates.Template{ID: "web", RequestsHTTP: []*http.Request{{}}, TotalRequests: 1}
ftp := &templates.Template{ID: "ftp", RequestsNetwork: []*network.Request{{Port: "21"}}, TotalRequests: 1}
ssh := &templates.Template{ID: "ssh", RequestsNetwork: []*network.Request{{Port: "22"}}, TotalRequests: 1}
all := []*templates.Template{web, ftp, ssh}
groups := []hostGroup{
{key: "http|80", httpOK: true, openPorts: map[string]struct{}{"80": {}}, hosts: metaInputs("h1", "h2")},
{key: "nohttp|21", httpOK: false, openPorts: map[string]struct{}{"21": {}}, hosts: metaInputs("h3")},
{key: "nohttp|22", httpOK: false, openPorts: map[string]struct{}{"22": {}}, hosts: metaInputs("h4")},
}
baseline, filtered := estimateGroupedExecutions(all, groups, 4)
require.Equal(t, 12, baseline)
// web*2 + ftp*1 + ssh*1 = 4
require.Equal(t, 4, filtered)
require.Equal(t, 8, baseline-filtered)
}
func TestHostReachabilityAllow(t *testing.T) {
web := &templates.Template{ID: "web", RequestsHTTP: []*http.Request{{}}}
ftp := &templates.Template{ID: "ftp", RequestsNetwork: []*network.Request{{Port: "21"}}}
redis := &templates.Template{ID: "redis", RequestsNetwork: []*network.Request{{Port: "6379"}}}
idx := &hostReachabilityIndex{byInput: map[string]hostReachability{
"h1": {httpOK: true, openPorts: map[string]struct{}{"80": {}}},
"h2": {httpOK: false, openPorts: map[string]struct{}{"21": {}}},
"127.0.0.1:19637": {httpOK: false, openPorts: map[string]struct{}{"19637": {}}, explicitPort: "19637"},
}}
require.True(t, idx.Allow(web, &contextargs.MetaInput{Input: "h1"}))
require.False(t, idx.Allow(web, &contextargs.MetaInput{Input: "h2"}))
require.False(t, idx.Allow(ftp, &contextargs.MetaInput{Input: "h1"}))
require.True(t, idx.Allow(ftp, &contextargs.MetaInput{Input: "h2"}))
require.True(t, idx.Allow(web, &contextargs.MetaInput{Input: "unknown"})) // lossless
// Explicit host:port must allow network templates even when template default
// port (6379) differs from the operator-specified port (19637).
require.True(t, idx.Allow(redis, &contextargs.MetaInput{Input: "127.0.0.1:19637"}))
require.False(t, idx.Allow(web, &contextargs.MetaInput{Input: "127.0.0.1:19637"}))
}
func TestTemplateAllowedOnHostUniversal(t *testing.T) {
dnsish := &templates.Template{ID: "dns", RequestsDNS: nil} // no concrete network ports
// force universal via empty network dynamic port
dyn := &templates.Template{ID: "dyn", RequestsNetwork: []*network.Request{{Port: ""}}}
require.True(t, templateAllowedOnHost(dyn, false, map[string]struct{}{}, ""))
_ = dnsish
self := &templates.Template{ID: "self", SelfContained: true}
require.True(t, templateAllowedOnHost(self, false, nil, ""))
}
func TestTemplateAllowedOnHostExplicitPort(t *testing.T) {
redis := &templates.Template{ID: "redis", RequestsNetwork: []*network.Request{{Port: "6379"}}}
web := &templates.Template{ID: "web", RequestsHTTP: []*http.Request{{}}}
open := map[string]struct{}{"19637": {}}
require.True(t, templateAllowedOnHost(redis, false, open, "19637"))
require.False(t, templateAllowedOnHost(redis, false, open, "")) // bare host: need 6379
require.False(t, templateAllowedOnHost(web, false, open, "19637"))
// Closed explicit port: openPorts empty => network templates denied.
require.False(t, templateAllowedOnHost(redis, false, map[string]struct{}{}, "19637"))
}
func TestTemplatesForHostGroupExplicitPort(t *testing.T) {
redis := &templates.Template{ID: "redis", RequestsNetwork: []*network.Request{{Port: "6379"}}}
ftp := &templates.Template{ID: "ftp", RequestsNetwork: []*network.Request{{Port: "21"}}}
web := &templates.Template{ID: "web", RequestsHTTP: []*http.Request{{}}}
g := hostGroup{
httpOK: false,
openPorts: map[string]struct{}{"19637": {}},
explicitPort: "19637",
}
got := templatesForHostGroup([]*templates.Template{redis, ftp, web}, g)
require.Len(t, got, 2)
ids := map[string]bool{got[0].ID: true, got[1].ID: true}
require.True(t, ids["redis"] && ids["ftp"])
}
func TestCountReachabilityStats(t *testing.T) {
web := &templates.Template{ID: "web", RequestsHTTP: []*http.Request{{}}}
redis := &templates.Template{ID: "redis", RequestsNetwork: []*network.Request{{Port: "6379"}}}
pg := &templates.Template{ID: "pg", RequestsNetwork: []*network.Request{{Port: "5432,5432"}}}
dyn := &templates.Template{ID: "dyn", RequestsNetwork: []*network.Request{{Port: ""}}}
concrete, ports := countReachabilityStats([]*templates.Template{web, redis, pg, dyn})
require.Equal(t, 2, concrete)
require.Equal(t, 2, ports) // 6379 + 5432
}
func TestResolveHTTPReachability(t *testing.T) {
require.True(t, resolveHTTPReachability(&contextargs.MetaInput{Input: "http://web"}, nil, nil, nil))
require.True(t, resolveHTTPReachability(&contextargs.MetaInput{Input: "https://web"}, nil, nil, nil))
require.False(t, resolveHTTPReachability(&contextargs.MetaInput{Input: "redis1"}, map[string]struct{}{}, nil, nil))
require.True(t, resolveHTTPReachability(&contextargs.MetaInput{Input: "host"}, map[string]struct{}{"80": {}}, nil, nil))
require.True(t, resolveHTTPReachability(&contextargs.MetaInput{Input: "host"}, map[string]struct{}{"443": {}}, nil, nil))
}
func TestResolveHTTPReachabilitySkippedProbeFailOpen(t *testing.T) {
// MultiFormat leaves an empty InputsHTTP map without probing — must not
// force non-HTTP when open ports still suggest HTTP.
helper := &input.Helper{InputsHTTPProbed: false}
require.True(t, resolveHTTPReachability(&contextargs.MetaInput{Input: "host"}, map[string]struct{}{"80": {}}, helper, nil))
}
func TestTemplatesForHostGroupSkipsUniversal(t *testing.T) {
web := &templates.Template{ID: "web", RequestsHTTP: []*http.Request{{}}}
univ := &templates.Template{ID: "univ", RequestsNetwork: []*network.Request{{Port: ""}}}
g := hostGroup{httpOK: true, openPorts: map[string]struct{}{"80": {}}}
got := templatesForHostGroup([]*templates.Template{web, univ}, g)
require.Len(t, got, 1)
require.Equal(t, "web", got[0].ID)
}
func TestEstimateGroupedIncludesUniversalsOnce(t *testing.T) {
web := &templates.Template{ID: "web", RequestsHTTP: []*http.Request{{}}, TotalRequests: 1}
univ := &templates.Template{ID: "univ", RequestsNetwork: []*network.Request{{Port: ""}}, TotalRequests: 1}
groups := []hostGroup{
{httpOK: true, openPorts: map[string]struct{}{"80": {}}, hosts: metaInputs("h1")},
}
baseline, filtered := estimateGroupedExecutions([]*templates.Template{web, univ}, groups, 1)
require.Equal(t, 2, baseline)
// universal counted once for all hosts + web for http group
require.Equal(t, 2, filtered)
}
func metaInputs(hosts ...string) []*contextargs.MetaInput {
out := make([]*contextargs.MetaInput, 0, len(hosts))
for _, h := range hosts {
mi := contextargs.NewMetaInput()
mi.Input = h
out = append(out, mi)
}
return out
}
+3
View File
@@ -39,6 +39,9 @@ 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 {
+35 -1
View File
@@ -2,8 +2,12 @@ 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"
@@ -28,6 +32,32 @@ 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.
@@ -52,5 +82,9 @@ func configureLLM(options *types.Options) (llmclient.Client, error) {
return nil, err
}
return &llmMatcherClient{client: client, model: options.LLMModel}, nil
matcherClient := &llmMatcherClient{client: client, model: options.LLMModel}
if client.CanJudge() {
return &llmJudgeClient{llmMatcherClient: matcherClient}, nil
}
return matcherClient, nil
}
+65
View File
@@ -0,0 +1,65 @@
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())
}
+4 -15
View File
@@ -228,25 +228,14 @@ func ValidateOptions(options *types.Options) error {
}
}
// 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
// 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.
for _, ipv := range options.IPVersion {
switch ipv {
case "4":
useIPV4 = true
case "6":
useIPV6 = true
default:
if ipv != "4" && ipv != "6" {
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,3 +90,15 @@ 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")
}
-104
View File
@@ -1,104 +0,0 @@
package runner
import (
"sync"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
)
// planScope narrows the templates run on each target by the scan plan filters
// (strict-probe reachability, tech filter), on top of the per-target profiles
// in base. It is a protocols.TargetScope, so the engine skips, counts and
// narrows clusters for these pairs the same way it does for profiles.
type planScope struct {
base protocols.TargetScope
allow func(*templates.Template, *contextargs.MetaInput) bool
// byPath maps a template path to its template. A path shared by several
// templates, or empty, is left out, so such templates are never filtered.
byPath map[string]*templates.Template
mu sync.RWMutex
selections map[planKey]*planSelection
}
// planKey identifies one selection: the target and the profile selection it
// narrows. Profile selections are comparable by contract.
type planKey struct {
input string
ip string
base protocols.TemplateSelection
}
// planSelection is the selection of one target.
type planSelection struct {
base protocols.TemplateSelection
scope *planScope
input *contextargs.MetaInput
}
var _ protocols.TargetScope = (*planScope)(nil)
func newPlanScope(base protocols.TargetScope, allow func(*templates.Template, *contextargs.MetaInput) bool, tpls []*templates.Template) *planScope {
byPath := make(map[string]*templates.Template, len(tpls))
shared := make(map[string]struct{})
for _, t := range tpls {
if t == nil || t.Path == "" {
continue
}
if _, ok := byPath[t.Path]; ok {
shared[t.Path] = struct{}{}
continue
}
byPath[t.Path] = t
}
for path := range shared {
delete(byPath, path)
}
return &planScope{
base: base,
allow: allow,
byPath: byPath,
selections: make(map[planKey]*planSelection),
}
}
// For returns the selection for input. It is the same value for the same
// input, so the engine can group targets by selection.
func (s *planScope) For(input *contextargs.MetaInput) protocols.TemplateSelection {
var base protocols.TemplateSelection
if s.base != nil {
base = s.base.For(input)
}
if input == nil {
return base
}
key := planKey{input: input.Input, ip: input.CustomIP, base: base}
s.mu.RLock()
sel, ok := s.selections[key]
s.mu.RUnlock()
if ok {
return sel
}
s.mu.Lock()
defer s.mu.Unlock()
if sel, ok := s.selections[key]; ok {
return sel
}
sel = &planSelection{base: base, scope: s, input: input.Clone()}
s.selections[key] = sel
return sel
}
// Allows reports whether the profiles and the plan filters select the template.
func (p *planSelection) Allows(templatePath string) bool {
if p.base != nil && !p.base.Allows(templatePath) {
return false
}
t, ok := p.scope.byPath[templatePath]
if !ok {
return true
}
return p.scope.allow(t, p.input)
}
-52
View File
@@ -1,52 +0,0 @@
package runner
import (
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/stretchr/testify/require"
)
// profileScope selects by path, like per-target profiles do.
type profileScope struct{ sel *profileSelection }
func (p profileScope) For(*contextargs.MetaInput) protocols.TemplateSelection { return p.sel }
type profileSelection struct{ allowed map[string]bool }
func (p *profileSelection) Allows(path string) bool { return p.allowed[path] }
func TestPlanScope(t *testing.T) {
web := &templates.Template{ID: "web", Path: "/t/web.yaml"}
net := &templates.Template{ID: "net", Path: "/t/net.yaml"}
dupA := &templates.Template{ID: "dup-a", Path: "/t/dup.yaml"}
dupB := &templates.Template{ID: "dup-b", Path: "/t/dup.yaml"}
// the filter drops the network template on host-a only
allow := func(tpl *templates.Template, mi *contextargs.MetaInput) bool {
return mi.Input != "host-a" || tpl.ID == "web"
}
input := func(raw string) *contextargs.MetaInput {
mi := contextargs.NewMetaInput()
mi.Input = raw
return mi
}
a, b := input("host-a"), input("host-b")
scope := newPlanScope(nil, allow, []*templates.Template{web, net, dupA, dupB})
selA := scope.For(a)
require.Same(t, selA, scope.For(input("host-a")), "one selection per target, so the engine can group targets")
require.True(t, web.SelectedBy(selA))
require.False(t, net.SelectedBy(selA))
require.True(t, dupA.SelectedBy(selA), "a path shared by several templates is never filtered")
require.True(t, net.SelectedBy(scope.For(b)))
require.True(t, (&templates.Template{Path: "/t/unknown.yaml"}).SelectedBy(selA), "unknown templates are never filtered")
// The plan narrows the profiles, never widens them.
profiles := profileScope{sel: &profileSelection{allowed: map[string]bool{"/t/net.yaml": true}}}
scope = newPlanScope(profiles, allow, []*templates.Template{web, net})
require.False(t, web.SelectedBy(scope.For(b)), "a template the profile leaves out stays out")
require.True(t, net.SelectedBy(scope.For(b)))
require.False(t, net.SelectedBy(scope.For(a)))
}
-199
View File
@@ -1,199 +0,0 @@
package runner
import (
"context"
"net"
"net/url"
"strconv"
"strings"
"time"
"github.com/projectdiscovery/httpx/common/httpx"
"github.com/projectdiscovery/nuclei/v3/pkg/input/provider"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/network"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/projectdiscovery/nuclei/v3/pkg/utils"
sliceutil "github.com/projectdiscovery/utils/slice"
)
type probeResult int
const (
portClosed probeResult = iota // connection refused / host unreachable
portOpen // connect succeeded
portUnknown // timeout / filtered — treat as possibly open
)
const reachabilityProbeTimeout = 750 * time.Millisecond
// strictProbeEnabled reports whether the lossless reachability prune
// applies to this run. It is limited to standard target inputs; request-shaped
// (DAST / multi-format) inputs are never pruned, because probing a request
// template as a live service is meaningless and could drop coverage.
func (r *Runner) strictProbeEnabled() bool {
return r.options.StrictProbe &&
r.inputProvider != nil &&
r.inputProvider.InputType() != provider.MultiFormatInputProvider
}
// noHTTPServiceReachable reports whether NO target actually speaks HTTP(S).
//
// When it returns true, web templates (HTTP/headless/websocket) can be excluded
// losslessly: a port that does not serve HTTP has no HTTP application, so no
// HTTP template can produce a real finding there. Unlike a bare TCP-open check,
// this catches the open-but-non-HTTP case (e.g. redis on 6379): the port is
// open, but it is not an HTTP service, so HTTP templates are pure waste.
//
// It reuses nuclei's own httpx probe (the same one used for input routing), so
// the determination is consistent with the engine and respects the network
// policy (the probe dials only through the policy-configured httpx client).
// Conservative: an explicit http/https target, any scheme that responds, or the
// inability to probe safely all keep web templates in place.
// Skipped when -no-httpx is set so we do not probe or prune against user intent.
func (r *Runner) noHTTPServiceReachable() bool {
if r.inputProvider == nil || r.options.DisableHTTPProbe {
return false
}
dialers := protocolstate.GetDialersWithId(r.options.ExecutionId)
if dialers == nil {
return false // cannot probe within policy — keep web templates
}
httpxOptions := httpx.DefaultOptions
if r.options.AliveHttpProxy != "" {
httpxOptions.Proxy = r.options.AliveHttpProxy
} else if r.options.AliveSocksProxy != "" {
httpxOptions.Proxy = r.options.AliveSocksProxy
}
httpxOptions.RetryMax = r.options.Retries
if r.options.Timeout > 0 {
httpxOptions.Timeout = time.Duration(r.options.Timeout) * time.Second
}
httpxOptions.NetworkPolicy = dialers.NetworkPolicy
client, err := httpx.New(&httpxOptions)
if err != nil {
return false // cannot probe — keep web templates
}
anyHTTP := false
r.inputProvider.Iterate(func(mi *contextargs.MetaInput) bool {
if strings.HasPrefix(mi.Input, "http://") || strings.HasPrefix(mi.Input, "https://") {
anyHTTP = true
return false
}
if utils.ProbeURL(mi.Input, client) != "" {
anyHTTP = true
return false
}
return true
})
return !anyHTTP
}
// tcpNetworkOnlyPorts returns the concrete numeric TCP ports of a single-protocol
// network template, or ok=false if the template is ineligible for port pruning.
func tcpNetworkOnlyPorts(t *templates.Template) ([]string, bool) {
if len(t.RequestsNetwork)+len(t.RequestsWithTCP) == 0 {
return nil, false
}
// must be network-only (no other protocol request could still be reachable)
otherReqs := len(t.RequestsHTTP) + len(t.RequestsWithHTTP) + len(t.RequestsHeadless) +
len(t.RequestsDNS) + len(t.RequestsFile) + len(t.RequestsSSL) +
len(t.RequestsCode) + len(t.RequestsJavascript) + len(t.Workflows)
if otherReqs > 0 {
return nil, false
}
reqs := make([]*network.Request, 0, len(t.RequestsNetwork)+len(t.RequestsWithTCP))
reqs = append(reqs, t.RequestsNetwork...)
reqs = append(reqs, t.RequestsWithTCP...)
var ports []string
for _, req := range reqs {
for _, addr := range req.Address {
if strings.Contains(strings.ToLower(addr), "udp://") {
return nil, false // UDP: a TCP probe cannot prove unreachability
}
}
pp := splitPorts(req.Port)
if len(pp) == 0 {
return nil, false // empty/dynamic port (uses input port) — keep
}
for _, p := range pp {
if !isNumericPort(p) {
return nil, false // service name or template var — keep
}
ports = append(ports, p)
}
}
if len(ports) == 0 {
return nil, false
}
return sliceutil.Dedupe(ports), true
}
func isNumericPort(p string) bool {
n, err := strconv.Atoi(p)
return err == nil && n > 0 && n < 65536
}
// hostAndExplicitPort splits a target into host and explicit port (port empty if
// the target is a bare host).
func hostAndExplicitPort(input string) (string, string) {
if strings.Contains(input, "://") {
if u, err := url.Parse(input); err == nil && u.Hostname() != "" {
return u.Hostname(), u.Port()
}
}
if host, port, err := net.SplitHostPort(input); err == nil {
return host, port
}
return input, ""
}
// probe classifies reachability of host:port WITHIN the scan's network policy.
// Security first: it never dials a target the policy forbids and never bypasses
// the sandbox — all probing goes through the execution's fastdialer, which
// enforces the same DenyList (‑lna, exclude-targets, private/metadata ranges,
// DNS-rebind protection) as the scan itself.
//
// It reconciles security with losslessness by mapping every uncertain case to
// portUnknown ("keep, don't dial"): a policy-denied host, a missing dialer, or a
// timeout all leave templates in place. Pruning happens only on a definitive,
// policy-permitted, refused connection.
func (r *Runner) probe(host, port string) probeResult {
if !protocolstate.IsHostAllowed(r.options.ExecutionId, host) {
// Policy denies this host: do not dial (security), do not prune (lossless).
return portUnknown
}
dialers := protocolstate.GetDialersWithId(r.options.ExecutionId)
if dialers == nil || dialers.Fastdialer == nil {
// No policy-enforcing dialer available: never fall back to a raw dial.
return portUnknown
}
return classifyDial(dialers.Fastdialer.Dial, net.JoinHostPort(host, port), reachabilityProbeTimeout)
}
type dialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
// classifyDial performs one connect via the supplied (policy-enforcing) dialer.
func classifyDial(dial dialFunc, addr string, timeout time.Duration) probeResult {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
conn, err := dial(ctx, "tcp", addr)
if err == nil {
_ = conn.Close()
return portOpen
}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
return portUnknown
}
msg := strings.ToLower(err.Error())
// Only definitive negatives prune. DNS blips / temporary dialer errors must
// stay unknown so mixed-fleet probing under load remains lossless.
if strings.Contains(msg, "refused") || strings.Contains(msg, "unreachable") ||
strings.Contains(msg, "no route to host") || strings.Contains(msg, "network is unreachable") {
return portClosed
}
return portUnknown
}
-133
View File
@@ -1,133 +0,0 @@
package runner
import (
"context"
"net"
"testing"
"time"
httpproto "github.com/projectdiscovery/nuclei/v3/pkg/protocols/http"
jsproto "github.com/projectdiscovery/nuclei/v3/pkg/protocols/javascript"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/network"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
)
func TestHostAndExplicitPort(t *testing.T) {
cases := []struct{ in, host, port string }{
{"127.0.0.1", "127.0.0.1", ""},
{"127.0.0.1:6379", "127.0.0.1", "6379"},
{"http://example.com:8080", "example.com", "8080"},
{"https://example.com", "example.com", ""},
}
for _, c := range cases {
h, p := hostAndExplicitPort(c.in)
if h != c.host || p != c.port {
t.Errorf("hostAndExplicitPort(%q) = (%q,%q), want (%q,%q)", c.in, h, p, c.host, c.port)
}
}
}
func TestIsNumericPort(t *testing.T) {
for _, p := range []string{"80", "6379", "65535", "1"} {
if !isNumericPort(p) {
t.Errorf("isNumericPort(%q) = false, want true", p)
}
}
for _, p := range []string{"ftp", "{{Port}}", "0", "70000", "", "80a"} {
if isNumericPort(p) {
t.Errorf("isNumericPort(%q) = true, want false", p)
}
}
}
// TestTCPNetworkOnlyPorts locks in the losslessness guards: only single-protocol
// network templates with concrete numeric TCP ports are eligible for pruning.
func TestTCPNetworkOnlyPorts(t *testing.T) {
netTmpl := func(addr []string, port string) *templates.Template {
return &templates.Template{RequestsNetwork: []*network.Request{{Address: addr, Port: port}}}
}
t.Run("concrete tcp port eligible", func(t *testing.T) {
ports, ok := tcpNetworkOnlyPorts(netTmpl([]string{"{{Hostname}}"}, "6379,6380"))
if !ok || len(ports) != 2 {
t.Fatalf("want ok with 2 ports, got ok=%v ports=%v", ok, ports)
}
})
t.Run("tls address still tcp", func(t *testing.T) {
if _, ok := tcpNetworkOnlyPorts(netTmpl([]string{"tls://{{Hostname}}"}, "6379")); !ok {
t.Fatal("tls:// should remain eligible (still TCP)")
}
})
t.Run("udp address rejected", func(t *testing.T) {
if _, ok := tcpNetworkOnlyPorts(netTmpl([]string{"udp://{{Hostname}}"}, "161")); ok {
t.Fatal("udp:// must be ineligible — a TCP probe cannot prove UDP unreachability")
}
})
t.Run("service-name port rejected", func(t *testing.T) {
if _, ok := tcpNetworkOnlyPorts(netTmpl([]string{"{{Hostname}}"}, "ftp")); ok {
t.Fatal("service-name port must be ineligible")
}
})
t.Run("dynamic port rejected", func(t *testing.T) {
if _, ok := tcpNetworkOnlyPorts(netTmpl([]string{"{{Hostname}}"}, "{{Port}}")); ok {
t.Fatal("template-var port must be ineligible")
}
})
t.Run("empty port rejected", func(t *testing.T) {
if _, ok := tcpNetworkOnlyPorts(netTmpl([]string{"{{Hostname}}"}, "")); ok {
t.Fatal("empty port (uses input port) must be ineligible")
}
})
t.Run("mixed protocol rejected", func(t *testing.T) {
mixed := netTmpl([]string{"{{Hostname}}"}, "6379")
mixed.RequestsHTTP = []*httpproto.Request{{}}
if _, ok := tcpNetworkOnlyPorts(mixed); ok {
t.Fatal("template with an HTTP request must be ineligible")
}
})
t.Run("javascript protocol rejected", func(t *testing.T) {
js := &templates.Template{RequestsJavascript: []*jsproto.Request{{}}}
if _, ok := tcpNetworkOnlyPorts(js); ok {
t.Fatal("javascript template must be ineligible (opaque dial protocol)")
}
})
}
func TestClassifyDial(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Skip("cannot listen:", err)
}
defer func() { _ = ln.Close() }()
d := &net.Dialer{}
if got := classifyDial(d.DialContext, ln.Addr().String(), reachabilityProbeTimeout); got != portOpen {
t.Errorf("classifyDial(open) = %v, want portOpen", got)
}
// an unused low port on loopback should refuse quickly
if got := classifyDial(d.DialContext, "127.0.0.1:1", reachabilityProbeTimeout); got != portClosed {
t.Errorf("classifyDial(closed) = %v, want portClosed", got)
}
}
func TestClassifyDialDNSFailureIsUnknown(t *testing.T) {
// NXDOMAIN / resolution failure must not prune (lossless under load).
dial := func(ctx context.Context, network, addr string) (net.Conn, error) {
return nil, &net.OpError{Op: "dial", Net: network, Err: &net.DNSError{Err: "no such host", Name: "no-such-host.invalid", IsNotFound: true}}
}
got := classifyDial(dial, "no-such-host.invalid:6379", reachabilityProbeTimeout)
if got != portUnknown {
t.Fatalf("classifyDial(dns-fail) = %v, want portUnknown", got)
}
}
func TestClassifyDialTimeoutIsUnknown(t *testing.T) {
// A dropped connect runs into the timeout and must not prune.
dial := func(ctx context.Context, network, addr string) (net.Conn, error) {
<-ctx.Done()
return nil, &net.OpError{Op: "dial", Net: network, Err: ctx.Err()}
}
got := classifyDial(dial, "192.0.2.1:65535", 50*time.Millisecond)
if got != portUnknown {
t.Fatalf("classifyDial(timeout) = %v, want portUnknown", got)
}
}
+7 -175
View File
@@ -45,8 +45,6 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/disk"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
"github.com/projectdiscovery/nuclei/v3/pkg/core"
scanplan "github.com/projectdiscovery/nuclei/v3/pkg/core/plan"
"github.com/projectdiscovery/nuclei/v3/pkg/core/techfilter"
"github.com/projectdiscovery/nuclei/v3/pkg/external/customtemplates"
fuzzStats "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/stats"
"github.com/projectdiscovery/nuclei/v3/pkg/input"
@@ -69,19 +67,16 @@ 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/protocols/http/httprespcache"
"github.com/projectdiscovery/nuclei/v3/pkg/reporting"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
templateTypes "github.com/projectdiscovery/nuclei/v3/pkg/templates/types"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/nuclei/v3/pkg/types/scanstrategy"
"github.com/projectdiscovery/nuclei/v3/pkg/utils"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/stats"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
"github.com/projectdiscovery/retryablehttp-go"
ptrutil "github.com/projectdiscovery/utils/ptr"
stringsutil "github.com/projectdiscovery/utils/strings"
)
var (
@@ -695,12 +690,6 @@ func (r *Runner) RunEnumeration() error {
DoNotCache: r.options.DoNotCacheTemplates,
Logger: r.Logger,
}
// Attach scan-scoped HTTP response cache only for opt-in -tf, and only
// before templates compile so request executers share the same pointer.
// Default scans leave this nil (no behavioral change).
if r.options.UsesResponseCache() {
executorOpts.HTTPResponseCache = httprespcache.New()
}
if config.DefaultConfig.IsDebugArgEnabled(config.DebugExportURLPattern) {
// Go StdLib style experimental/debug feature switch
@@ -746,6 +735,9 @@ 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)
@@ -758,16 +750,6 @@ func (r *Runner) RunEnumeration() error {
// If using input-file flags, only load http fuzzing based templates.
loaderConfig := loader.NewConfig(r.options, r.catalog, executorOpts)
// Lossless reachability prune: if no web port is reachable on any target,
// exclude web-protocol templates at load time — they cannot connect, so
// cannot produce a finding. Reuses the standard protocol-type exclusion on
// the loader config only, so shared options stay unchanged across runs.
if r.strictProbeEnabled() && r.noHTTPServiceReachable() {
loaderConfig.ExcludeProtocols = append(slices.Clone(loaderConfig.ExcludeProtocols),
templateTypes.HTTPProtocol, templateTypes.HeadlessProtocol, templateTypes.WebsocketProtocol)
r.Logger.Info().Msgf("reachability prune: no HTTP service on any target; excluding web-protocol templates (lossless)")
}
profiles := r.targetProfiles()
if profiles != nil {
if r.options.AutomaticScan {
@@ -879,15 +861,6 @@ func (r *Runner) RunEnumeration() error {
return errors.Wrap(err, "could not probe http input")
}
executorOpts.InputHelper.InputsHTTP = inputHelpers
executorOpts.InputHelper.InputsHTTPProbed = r.inputProvider.InputType() != provider.MultiFormatInputProvider
// Under strict-probe, skip web templates per-input for targets that
// probed as non-HTTP (lossless), instead of falling back to raw URL.
executorOpts.InputHelper.StrictProbe = r.strictProbeEnabled()
if r.strictProbeEnabled() {
r.Logger.Info().Msgf("Strict probe enabled: hosts without httpx confirmation are skipped for HTTP/headless templates")
}
} else if r.strictProbeEnabled() && r.options.DisableHTTPProbe {
r.Logger.Warning().Msgf("strict-probe has no effect with -no-httpx (httpx probing is disabled)")
}
inputCount := int(r.inputProvider.Count())
@@ -1062,157 +1035,15 @@ func (r *Runner) executeTemplatesInput(store *loader.Store, engine *core.Engine)
return nil, errors.New("no templates provided for scan")
}
// pass input provider to engine
// TODO: this should be not necessary after r.hmapInputProvider is removed + refactored
if r.inputProvider == nil {
return nil, errors.New("no input provider found")
}
return r.executeTemplatesWithScanPlan(engine, finalTemplates)
}
// executeTemplatesWithScanPlan chooses spray strategy and optionally attaches a
// reachability / tech filter, then runs a single Execute pass. Wall clock must
// match or beat baseline spray; probing is skipped when its cost would dominate.
func (r *Runner) executeTemplatesWithScanPlan(engine *core.Engine, finalTemplates []*templates.Template) (*atomic.Bool, error) {
defer r.attachScanPlan(engine, finalTemplates)()
results := engine.ExecuteScanWithOpts(context.Background(), finalTemplates, r.inputProvider, r.options.DisableClustering)
return results, nil
}
// attachScanPlan applies the scan plan to engine for one scan. It returns a
// func that undoes the per-scan state once the scan is done.
func (r *Runner) attachScanPlan(engine *core.Engine, finalTemplates []*templates.Template) func() {
var undo []func()
cleanup := func() {
for i := len(undo) - 1; i >= 0; i-- {
undo[i]()
}
}
hostCount := int(r.inputProvider.Count())
baselineReqs := estimateTemplateRequests(finalTemplates) * hostCount
concreteNet, portsToProbe := countReachabilityStats(finalTemplates)
techBound := techfilter.CountTechBound(finalTemplates)
planIn := scanplan.Input{
Hosts: hostCount,
Templates: len(finalTemplates),
Requests: baselineReqs,
BulkSize: r.options.BulkSize,
Stream: r.options.Stream,
StrictProbe: r.strictProbeEnabled(),
PortsToProbe: portsToProbe,
ConcreteNetworkTemplates: concreteNet,
TechFilter: r.options.TechFilter,
TechBoundTemplates: techBound,
}
p := scanplan.Decide(planIn)
// Resolve -ss auto before Execute so the engine placeholder is unused.
if stringsutil.EqualFoldAny(r.options.ScanStrategy, scanstrategy.Auto.String(), "") {
r.options.ScanStrategy = p.Strategy
}
var httpHelper *input.Helper
if opts := engine.ExecuterOptions(); opts != nil {
httpHelper = opts.InputHelper
}
var reachFilter func(*templates.Template, *contextargs.MetaInput) bool
var techFilterFn func(*templates.Template, *contextargs.MetaInput) bool
var techIdx *techReachabilityIndex
filteredReqs := baselineReqs
if p.BuildReachability {
idx, groups, err := r.buildHostReachability(finalTemplates, httpHelper, p.ProbePorts)
if err != nil {
r.Logger.Warning().Msgf("could not build host reachability index, continuing unfiltered: %s", err)
} else {
baseline, filtered := estimateGroupedExecutions(finalTemplates, groups, hostCount)
filteredReqs = filtered
saved := baseline - filtered
pct := 0.0
if baseline > 0 {
pct = 100 * float64(saved) / float64(baseline)
}
for _, g := range groups {
r.Logger.Info().Msgf("host-group %s: hosts=%d templates=%d", g.key, len(g.hosts), len(templatesForHostGroup(finalTemplates, g)))
}
r.Logger.Info().Msgf(
"host-groups: groups=%d baseline_exec=%d filtered_exec=%d saved=%d (%.1f%%) mode=single-pass",
len(groups), baseline, filtered, saved, pct,
)
if p.UseReachabilityFilter {
reachFilter = idx.Allow
}
}
}
if p.UseTechFilter {
var err error
var cache *httprespcache.Cache
if opts := engine.ExecuterOptions(); opts != nil {
if opts.HTTPResponseCache == nil {
opts.HTTPResponseCache = httprespcache.New()
}
cache = opts.HTTPResponseCache
}
techIdx, err = r.buildTechReachability(finalTemplates, httpHelper, cache)
if err != nil {
r.Logger.Warning().Msgf("could not build tech filter index, continuing without tech-filter: %s", err)
if cache != nil {
cache.Disable()
}
techIdx = nil
} else {
base, techFiltered := estimateTechFilteredExecutions(finalTemplates, techIdx, hostCount)
// When stacked with reachability, take the more aggressive (lower) estimate
// as a lower bound for progress; actual AND filter may skip more.
if techFiltered < filteredReqs {
filteredReqs = techFiltered
}
saved := base - techFiltered
pct := 0.0
if base > 0 {
pct = 100 * float64(saved) / float64(base)
}
r.Logger.Info().Msgf(
"tech-filter: baseline_exec=%d filtered_exec=%d saved=%d (%.1f%%) bound_templates=%d",
base, techFiltered, saved, pct, techBound,
)
techFilterFn = techIdx.Allow
}
} else if opts := engine.ExecuterOptions(); opts != nil && opts.HTTPResponseCache != nil && !r.options.ResponseCache {
// -tf set but planner skipped (e.g. no tech-bound templates): do not
// alter request paths via an idle response cache. Keep it when the user
// asked for -response-cache explicitly.
opts.HTTPResponseCache.Disable()
}
p.ApplyFiltered(filteredReqs)
if opts := engine.ExecuterOptions(); opts != nil {
// The filters narrow the templates per target through the same
// TargetScope as per-target profiles, on top of them. The engine then
// skips the pairs, counts progress and narrows clusters as it does for
// profiles.
if filter := composeTemplateFilters(reachFilter, techFilterFn); filter != nil {
profiles := opts.TargetScope
opts.TargetScope = newPlanScope(profiles, filter, finalTemplates)
undo = append(undo, func() { opts.TargetScope = profiles })
}
if opts.HTTPResponseCache != nil {
undo = append(undo, func() {
hits, misses, stores := opts.HTTPResponseCache.Stats()
r.Logger.Info().Msgf("http-response-cache: hits=%d misses=%d stores=%d", hits, misses, stores)
opts.HTTPResponseCache = nil
})
}
}
r.Logger.Info().Msgf("scan-plan: strategy=%s reach=%v tech=%v expected_req=%d reason=%s",
r.options.ScanStrategy, p.UseReachabilityFilter, p.UseTechFilter, p.ExpectedRequests, p.Reason)
return cleanup
}
// displayExecutionInfo prints parser stats, version info, and scan counts.
func (r *Runner) displayExecutionInfo(store *loader.Store) {
// Display parser stats for templates loaded into the store.
@@ -1245,6 +1076,7 @@ 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,11 +6,17 @@ 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"
@@ -197,3 +203,63 @@ 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())
}
-40
View File
@@ -1,40 +0,0 @@
package runner
import (
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/core"
"github.com/projectdiscovery/nuclei/v3/pkg/input/provider"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httprespcache"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
)
// AttachScanPlan applies -strict-probe and -tech-filter to an SDK engine for
// one scan, the same way the CLI runner does. It returns a func that undoes the
// per-scan state; call it once the scan is done. When neither option is set it
// does nothing, so default SDK scans keep their behavior.
func AttachScanPlan(opts *types.Options, inputProvider provider.InputProvider, logger *gologger.Logger, engine *core.Engine, tpls []*templates.Template) func() {
if opts == nil || engine == nil || inputProvider == nil || (!opts.StrictProbe && !opts.TechFilter) {
return func() {}
}
if logger == nil {
logger = gologger.DefaultLogger
}
// The SDK engine is reused across scans, so keep the options and the
// response cache it owns as they were before this scan.
strategy := opts.ScanStrategy
executerOpts := engine.ExecuterOptions()
var responseCache *httprespcache.Cache
if executerOpts != nil {
responseCache = executerOpts.HTTPResponseCache
}
r := &Runner{options: opts, inputProvider: inputProvider, Logger: logger}
cleanup := r.attachScanPlan(engine, tpls)
return func() {
cleanup()
opts.ScanStrategy = strategy
if executerOpts != nil {
executerOpts.HTTPResponseCache = responseCache
}
}
}
-44
View File
@@ -1,44 +0,0 @@
package runner
import (
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/core"
"github.com/projectdiscovery/nuclei/v3/pkg/input/provider"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httprespcache"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/stretchr/testify/require"
)
// Without -strict-probe or -tech-filter the SDK hook changes nothing.
func TestAttachScanPlanNoop(t *testing.T) {
opts := types.DefaultOptions()
opts.ScanStrategy = "auto"
engine := core.New(opts)
engine.SetExecuterOptions(&protocols.ExecutorOptions{Options: opts})
ip := provider.NewSimpleInputProviderWithUrls(opts.ExecutionId, "http://127.0.0.1:1")
AttachScanPlan(opts, ip, nil, engine, nil)()
require.Equal(t, "auto", opts.ScanStrategy)
require.Nil(t, engine.ExecuterOptions().TargetScope)
}
// The SDK engine is reused across scans, so the hook must leave its options
// and its response cache as they were before the scan.
func TestAttachScanPlanRestoresEngineState(t *testing.T) {
opts := types.DefaultOptions()
opts.ScanStrategy = "auto"
opts.TechFilter = true
cache := httprespcache.New()
engine := core.New(opts)
engine.SetExecuterOptions(&protocols.ExecutorOptions{Options: opts, HTTPResponseCache: cache})
ip := provider.NewSimpleInputProviderWithUrls(opts.ExecutionId, "http://127.0.0.1:1")
cleanup := AttachScanPlan(opts, ip, nil, engine, nil)
require.NotEqual(t, "auto", opts.ScanStrategy, "the plan resolves the auto strategy for the scan")
cleanup()
require.Equal(t, "auto", opts.ScanStrategy)
require.Same(t, cache, engine.ExecuterOptions().HTTPResponseCache)
require.Nil(t, engine.ExecuterOptions().TargetScope)
}
-283
View File
@@ -1,283 +0,0 @@
package runner
import (
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/core/techfilter"
"github.com/projectdiscovery/nuclei/v3/pkg/input"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
httpProtocol "github.com/projectdiscovery/nuclei/v3/pkg/protocols/http"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httpclientpool"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httprespcache"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/projectdiscovery/retryablehttp-go"
syncutil "github.com/projectdiscovery/utils/sync"
unitutils "github.com/projectdiscovery/utils/unit"
wappalyzer "github.com/projectdiscovery/wappalyzergo"
)
const (
techFilterWorkers = 50
techFilterMaxBody = 1 * unitutils.Mega
techFilterHTTPTimeout = 5 * time.Second
techFilterMaxRedirects = 5
)
// techReachabilityIndex filters template×target pairs by product/macro tags.
type techReachabilityIndex struct {
byInput map[string]techfilter.HostProfile // keyed by normalizeInputKey
// boundTags memoizes template ID -> product tags. It is filled lazily from
// the template execution goroutines, so every access needs the mutex.
tagsMu sync.RWMutex
boundTags map[string][]string
}
func normalizeInputKey(raw string) string {
return strings.TrimRight(strings.TrimSpace(raw), "/")
}
// Allow is fail-open: missing profile or unbound template => true.
func (idx *techReachabilityIndex) Allow(t *templates.Template, mi *contextargs.MetaInput) bool {
if idx == nil || t == nil || mi == nil {
return true
}
profile, ok := idx.lookup(mi.Input)
if !ok {
return true
}
return techfilter.AllowTags(profile, idx.tagsFor(t))
}
func (idx *techReachabilityIndex) tagsFor(t *templates.Template) []string {
if t == nil {
return nil
}
idx.tagsMu.RLock()
tags, ok := idx.boundTags[t.ID]
idx.tagsMu.RUnlock()
if ok {
return tags
}
tags = techfilter.TemplateProductTags(t)
idx.tagsMu.Lock()
defer idx.tagsMu.Unlock()
if idx.boundTags == nil {
idx.boundTags = make(map[string][]string)
}
// Another goroutine may have won the race; keep one slice per template ID.
if existing, ok := idx.boundTags[t.ID]; ok {
return existing
}
idx.boundTags[t.ID] = tags
return tags
}
func (idx *techReachabilityIndex) lookup(raw string) (techfilter.HostProfile, bool) {
if idx == nil || idx.byInput == nil {
return techfilter.HostProfile{}, false
}
if p, ok := idx.byInput[normalizeInputKey(raw)]; ok {
return p, true
}
if p, ok := idx.byInput[raw]; ok {
return p, true
}
return techfilter.HostProfile{}, false
}
// buildTechReachability fingerprints HTTP(S) targets and builds a tag index.
// When cache is non-nil, fingerprint GET responses seed it so matching template
// GETs pay no extra RTT.
func (r *Runner) buildTechReachability(tpls []*templates.Template, httpHelper *input.Helper, cache *httprespcache.Cache) (*techReachabilityIndex, error) {
_ = tpls
wapp, err := wappalyzer.New()
if err != nil {
return nil, err
}
// Redirects must be followed. A site that answers "/" with a 302 to its real
// entry point (WordPress -> /wp-admin/install.php, any app -> /login) returns
// an empty body, so the probe would detect nothing and every template bound
// to the undetected product would be pruned. Same-host only, so the probe
// never leaves the target authority.
httpclient, err := httpclientpool.Get(r.options, &httpclientpool.Configuration{
DisableCookie: true,
ResponseHeaderTimeout: techFilterHTTPTimeout,
RedirectFlow: httpclientpool.FollowSameHostRedirect,
MaxRedirects: techFilterMaxRedirects,
}, "")
if err != nil {
return nil, err
}
idx := &techReachabilityIndex{
byInput: make(map[string]techfilter.HostProfile),
boundTags: make(map[string][]string),
}
var mu sync.Mutex
sg, err := syncutil.New(syncutil.WithSize(techFilterWorkers))
if err != nil {
return nil, err
}
fingerprinted := 0
eligible := 0
r.inputProvider.Iterate(func(mi *contextargs.MetaInput) bool {
if mi == nil || mi.Input == "" {
return true
}
fpURL := fingerprintURLForInput(mi.Input, httpHelper)
if fpURL == "" {
return true
}
eligible++
sg.Add()
go func(inputKey, url string) {
defer sg.Done()
profile := fingerprintTarget(wapp, httpclient, url, cache)
key := normalizeInputKey(inputKey)
mu.Lock()
idx.byInput[key] = profile
if profile.HasTags() {
fingerprinted++
}
mu.Unlock()
}(mi.Input, fpURL)
return true
})
sg.Wait()
gologger.Info().Msgf("tech-filter: fingerprinted=%d/%d hosts with tags (eligible=%d)", fingerprinted, len(idx.byInput), eligible)
return idx, nil
}
// fingerprintURLForInput returns an absolute http(s) URL to fingerprint, or "".
// Bare hosts reuse InputsHTTP probed URLs when available.
func fingerprintURLForInput(raw string, helper *input.Helper) string {
if looksLikeHTTPTarget(raw) {
return raw
}
if helper != nil && helper.InputsHTTP != nil {
if probed, ok := helper.InputsHTTP.Get(raw); ok && len(probed) > 0 {
return string(probed)
}
}
return ""
}
func looksLikeHTTPTarget(raw string) bool {
lower := strings.ToLower(raw)
return strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://")
}
func fingerprintTarget(wapp *wappalyzer.Wappalyze, client *retryablehttp.Client, rawURL string, cache *httprespcache.Cache) techfilter.HostProfile {
// Prefer a trailing-slash form that matches typical {{BaseURL}}/ template URLs.
seedURL := rawURL
if !strings.HasSuffix(seedURL, "/") {
seedURL = seedURL + "/"
}
req, err := retryablehttp.NewRequest(http.MethodGet, seedURL, nil)
if err != nil {
return techfilter.HostProfile{}
}
// Same user agent the templates will send, so the response seeded below is
// reachable from the cache instead of being stored under a key nothing hits.
req.Header.Set("User-Agent", httpProtocol.StableUserAgent())
resp, err := client.Do(req)
if err != nil {
return techfilter.HostProfile{}
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, techFilterMaxBody))
if err != nil {
return techfilter.HostProfile{}
}
if cache != nil {
seedFingerprintResponse(cache, req, resp, body, seedURL)
}
info := wapp.FingerprintWithInfo(resp.Header, body)
products := make(map[string][]string, len(info))
for name, app := range info {
products[name] = append([]string(nil), app.Categories...)
}
return techfilter.ProfileFromFingerprint(products)
}
// seedFingerprintResponse stores the probe response under the URL it actually
// belongs to, so templates can reuse the RTT.
//
// The probe follows redirects, so resp may describe a different URL than the one
// requested. Seeding it under the requested URL would hand every template that
// asks for that URL the redirect target's status, headers and body instead of
// the redirect itself, which silently changes what they match on.
func seedFingerprintResponse(cache *httprespcache.Cache, req *retryablehttp.Request, resp *http.Response, body []byte, seedURL string) {
finalURL := seedURL
if resp != nil && resp.Request != nil && resp.Request.URL != nil {
finalURL = resp.Request.URL.String()
}
if finalURL == seedURL {
cache.SeedHTTP(req.Request, resp, body, techFilterMaxBody)
return
}
finalReq := req.Request.Clone(req.Context())
finalReq.URL = resp.Request.URL
finalReq.Host = ""
cache.SeedHTTP(finalReq, resp, body, techFilterMaxBody)
}
// estimateTechFilteredExecutions counts template×host pairs that survive the tech filter.
func estimateTechFilteredExecutions(tpls []*templates.Template, idx *techReachabilityIndex, hostCount int) (baseline, filtered int) {
baseline = estimateTemplateRequests(tpls) * hostCount
if idx == nil || len(idx.byInput) == 0 {
return baseline, baseline
}
filtered = 0
for _, t := range tpls {
reqs := t.TotalRequests
if reqs <= 0 {
reqs = 1
}
if len(t.Workflows) > 0 {
continue
}
tags := idx.tagsFor(t)
for _, profile := range idx.byInput {
if techfilter.AllowTags(profile, tags) {
filtered += reqs
}
}
missing := hostCount - len(idx.byInput)
if missing > 0 {
filtered += reqs * missing
}
}
return baseline, filtered
}
// composeTemplateFilters ANDs optional filters; nil filters are ignored.
func composeTemplateFilters(filters ...func(*templates.Template, *contextargs.MetaInput) bool) func(*templates.Template, *contextargs.MetaInput) bool {
var active []func(*templates.Template, *contextargs.MetaInput) bool
for _, f := range filters {
if f != nil {
active = append(active, f)
}
}
if len(active) == 0 {
return nil
}
return func(t *templates.Template, mi *contextargs.MetaInput) bool {
for _, f := range active {
if !f(t, mi) {
return false
}
}
return true
}
}
-33
View File
@@ -1,33 +0,0 @@
package runner
import (
"sync"
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/core/techfilter"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
)
// tagsFor is filled lazily from the template execution goroutines. Run it under
// -race to catch the concurrent map access that used to crash whole scans.
func TestTechReachabilityIndexConcurrentTagsFor(t *testing.T) {
idx := &techReachabilityIndex{
byInput: map[string]techfilter.HostProfile{},
boundTags: map[string][]string{},
}
var wg sync.WaitGroup
for i := 0; i < 64; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < 64; j++ {
tpl := &templates.Template{ID: string(rune('a' + (i+j)%26))}
_ = idx.tagsFor(tpl)
_ = idx.Allow(tpl, &contextargs.MetaInput{Input: "http://x/"})
}
}(i)
}
wg.Wait()
}
-70
View File
@@ -1,70 +0,0 @@
package runner
import (
"net/http"
"net/url"
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httprespcache"
"github.com/projectdiscovery/retryablehttp-go"
)
func mustReq(t *testing.T, raw string) *retryablehttp.Request {
t.Helper()
req, err := retryablehttp.NewRequest(http.MethodGet, raw, nil)
if err != nil {
t.Fatalf("request: %v", err)
}
return req
}
func respFor(raw string, status int) *http.Response {
u, _ := url.Parse(raw)
return &http.Response{
StatusCode: status,
Status: http.StatusText(status),
Header: http.Header{"Server": []string{"probe"}},
Request: &http.Request{Method: http.MethodGet, URL: u},
}
}
// The probe follows redirects. The body it comes back with belongs to the
// redirect target, so it must never be stored under the requested URL: a
// template asking for "/" must still see the redirect, not the target page.
func TestSeedFingerprintResponseAfterRedirect(t *testing.T) {
const seedURL = "http://example.test/"
const finalURL = "http://example.test/wp-admin/install.php"
c := httprespcache.New()
req := mustReq(t, seedURL)
req.Header.Set("User-Agent", "probe-ua")
seedFingerprintResponse(c, req, respFor(finalURL, 200), []byte("install page"), seedURL)
lookup := req.Request.Clone(req.Context())
if got := c.Get(httprespcache.KeyFromRequest(lookup), "template", lookup); got != nil {
t.Fatal("the redirect target must not be cached under the requested URL")
}
finalLookup := req.Request.Clone(req.Context())
finalLookup.URL, _ = url.Parse(finalURL)
finalLookup.Host = ""
if got := c.Get(httprespcache.KeyFromRequest(finalLookup), "template", finalLookup); got == nil {
t.Fatal("the response must be cached under the URL it belongs to")
}
}
// Without a redirect the response does describe the requested URL, so seeding
// it there is what saves the round trip.
func TestSeedFingerprintResponseWithoutRedirect(t *testing.T) {
const seedURL = "http://example.test/"
c := httprespcache.New()
req := mustReq(t, seedURL)
req.Header.Set("User-Agent", "probe-ua")
seedFingerprintResponse(c, req, respFor(seedURL, 200), []byte("home"), seedURL)
lookup := req.Request.Clone(req.Context())
if got := c.Get(httprespcache.KeyFromRequest(lookup), "template", lookup); got == nil {
t.Fatal("a non-redirected response must be cached under the requested URL")
}
}
-21
View File
@@ -1,21 +0,0 @@
package runner
import (
"testing"
"github.com/projectdiscovery/hmap/store/hybrid"
"github.com/projectdiscovery/nuclei/v3/pkg/input"
"github.com/stretchr/testify/require"
)
func TestFingerprintURLForInput(t *testing.T) {
require.Equal(t, "http://a/", fingerprintURLForInput("http://a/", nil))
require.Equal(t, "", fingerprintURLForInput("example.com", nil))
hm, err := hybrid.New(hybrid.DefaultDiskOptions)
require.NoError(t, err)
t.Cleanup(func() { _ = hm.Close() })
require.NoError(t, hm.Set("example.com", []byte("https://example.com")))
helper := &input.Helper{InputsHTTP: hm, InputsHTTPProbed: true}
require.Equal(t, "https://example.com", fingerprintURLForInput("example.com", helper))
}
+43 -8
View File
@@ -6,13 +6,15 @@ import (
"sort"
"strings"
"github.com/alecthomas/chroma/quick"
"github.com/alecthomas/chroma/v2/quick"
"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"
)
@@ -38,12 +40,17 @@ 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.Logger.Print().Msgf(
"\nListing available %v nuclei templates for %v",
config.DefaultConfig.TemplateVersion,
config.DefaultConfig.TemplatesDirectory,
)
r.listingHeader("templates")
// order templates alphabetically by path
templates := store.Templates()
sort.Slice(templates, func(i, j int) bool {
@@ -51,6 +58,10 @@ 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
@@ -78,12 +89,36 @@ func (r *Runner) listAvailableStoreTemplates(store *loader.Store) {
}
}
func (r *Runner) listAvailableTags(tagsMap map[string]int) {
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
}
r.Logger.Print().Msgf(
"\nListing available %v nuclei tags for %v",
"\nListing available %v nuclei %s 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
@@ -0,0 +1,76 @@
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,6 +33,7 @@ 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"
@@ -110,6 +111,9 @@ 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,6 +102,7 @@ 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{}
@@ -1983,3 +1984,59 @@ 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
}
@@ -0,0 +1,89 @@
//go:build integration
// +build integration
package integration_test
import (
"fmt"
"net"
"time"
"github.com/ory/dockertest/v3"
)
// ipmiSimSetup runs OpenIPMI's BMC simulator with an anonymous user,
// ADMIN/ADMIN and root/calvin, and cipher suite 0 enabled.
const ipmiSimSetup = `apt-get update -qq && apt-get install -y -qq --no-install-recommends openipmi >/dev/null && mkdir -p /etc/ipmi && cat > /etc/ipmi/lan.conf <<'EOF'
name "bmc"
set_working_mc 0x20
startlan 1
addr 0.0.0.0 623
priv_limit admin
allowed_auths_callback none md2 md5 straight
allowed_auths_user none md2 md5 straight
allowed_auths_operator none md2 md5 straight
allowed_auths_admin none md2 md5 straight
guid a123456789abcdefa123456789abcdef
endlan
user 1 true "" "" user 10 none md2 md5 straight
user 2 true "ADMIN" "ADMIN" admin 10 none md2 md5 straight
user 3 true "root" "calvin" admin 10 none md2 md5 straight
EOF
printf 'mc_setbmc 0x20\nmc_add 0x20 0 no-device-sdrs 0x23 9 8 0x9f 0x1291 0xf02 persist_sdr\nmc_enable 0x20\n' > /etc/ipmi/emu.cmd
exec ipmi_sim -c /etc/ipmi/lan.conf -f /etc/ipmi/emu.cmd -n`
type javascriptIPMI struct{}
func (j *javascriptIPMI) Execute(filePath string) error {
pool, err := dockertest.NewPool("")
if err != nil {
return fmt.Errorf("could not create docker pool: %w", err)
}
if err := pool.Client.Ping(); err != nil {
return fmt.Errorf("could not connect to Docker: %w", err)
}
resource, err := pool.RunWithOptions(&dockertest.RunOptions{
Repository: "debian",
Tag: "bookworm-slim",
Platform: "linux/amd64",
Cmd: []string{"sh", "-c", ipmiSimSetup},
ExposedPorts: []string{"623/udp"},
})
if err != nil {
return fmt.Errorf("could not start ipmi simulator: %w", err)
}
defer purge(pool, resource)
address := net.JoinHostPort("127.0.0.1", resource.GetPort("623/udp"))
if err := waitForServiceCheck(address, 3*time.Minute, rmcpPingCheck); err != nil {
return err
}
results, err := runSignedNucleiTemplateAndGetResults(filePath, address, debug)
if err != nil {
return err
}
return expectResultsCount(results, 1)
}
// rmcpPingCheck waits for an answer to an RMCP presence ping, since the
// port is udp and cannot be probed with a connect.
func rmcpPingCheck(address string) error {
conn, err := net.Dial("udp", address)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
if _, err := conn.Write([]byte{0x06, 0x00, 0xff, 0x06, 0x00, 0x00, 0x11, 0xbe, 0x80, 0x00, 0x00, 0x00}); err != nil {
return err
}
_ = conn.SetReadDeadline(time.Now().Add(time.Second))
reply := make([]byte, 64)
n, err := conn.Read(reply)
if err != nil {
return err
}
if n < 9 || reply[8] != 0x40 {
return fmt.Errorf("unexpected rmcp reply")
}
return nil
}
@@ -46,6 +46,7 @@ var jsTestcases = []integrationCase{
{Path: "protocols/javascript/http-get.yaml", TestCase: &javascriptHTTPGet{}},
{Path: "protocols/javascript/http-client-flow.yaml", TestCase: &javascriptHTTPClientFlow{}},
{Path: "protocols/javascript/http-denied.yaml", TestCase: &javascriptHTTPDenied{}},
{Path: "protocols/javascript/ipmi.yaml", TestCase: &javascriptIPMI{}, DisableOn: javascriptDockerDisabled, Serial: true},
{Path: "protocols/javascript/rsync-test.yaml", TestCase: &javascriptRsyncTest{}, DisableOn: javascriptDockerDisabled, Serial: true},
{Path: "protocols/javascript/vnc-pass-brute.yaml", TestCase: &javascriptVncPassBrute{}, DisableOn: javascriptDockerDisabled, Serial: true},
{Path: "protocols/javascript/postgres-pass-brute.yaml", TestCase: &javascriptPostgresPassBrute{}, DisableOn: javascriptDockerDisabled, Serial: true},
@@ -0,0 +1,46 @@
id: ipmi-checks
info:
name: IPMI Checks
author: pdteam
severity: info
javascript:
- code: |
const ipmi = require('nuclei/ipmi');
const caps = ipmi.GetAuthCapabilities(Host, Port);
const root = ipmi.GetRAKPHash(Host, Port, 'root');
// cipher suite 0 lets a wrong password in
const anyPassword = new ipmi.Client();
const cipherZero = anyPassword.Login(Host, Port, 'ADMIN', 'wrong', 0);
anyPassword.Close();
// an encrypted, signed session, then Get Device ID and admin privilege
const client = new ipmi.Client();
client.Login(Host, Port, 'ADMIN', 'ADMIN', 3);
const device = client.Command(0x06, 0x01, []);
const admin = client.Command(0x06, 0x3b, [0x04]);
client.Close();
const manufacturer = device.Data[6] | device.Data[7] << 8 | device.Data[8] << 16;
let wrongRejected = false;
try { new ipmi.Client().Login(Host, Port, 'ADMIN', 'wrong', 3); } catch (e) { wrongRejected = true; }
ipmi.Ping(Host, Port) && caps.IPMI20 && caps.AnonymousLogin &&
ipmi.GetCipherSuites(Host, Port).includes(0) &&
ipmi.GetSystemGUID(Host, Port).length == 32 &&
ipmi.FindRAKPPassword(root, ['admin', 'calvin']) == 'calvin' &&
!ipmi.CheckRAKPPassword(root, 'admin') &&
cipherZero && wrongRejected &&
device.CompletionCode == 0 && manufacturer == 4753 &&
admin.CompletionCode == 0;
args:
Host: "{{Host}}"
Port: "623"
matchers:
- type: dsl
dsl:
- "success == true"
-38
View File
@@ -173,16 +173,6 @@ func WithResponseReadSize(responseReadSize int) NucleiSDKOptions {
}
}
// EnableResponseCache reuses identical safe GET/HEAD responses within one scan.
// It is off by default: a template that depends on state or timing could see a
// different result.
func EnableResponseCache() NucleiSDKOptions {
return func(e *NucleiEngine) error {
e.opts.ResponseCache = true
return nil
}
}
// WithGlobalRateLimit sets global rate (i.e all hosts combined) limit options
// Deprecated: will be removed in favour of WithGlobalRateLimitCtx in next release
func WithGlobalRateLimit(maxTokens int, duration time.Duration) NucleiSDKOptions {
@@ -766,31 +756,3 @@ func WithTemporaryDirectory(parentDir string) NucleiSDKOptions {
return nil
}
}
// WithPerHostRateLimit applies the rate limit per host instead of globally.
func WithPerHostRateLimit(enabled bool) NucleiSDKOptions {
return func(e *NucleiEngine) error {
e.opts.PerHostRateLimit = enabled
return nil
}
}
// EnableStrictProbe skips templates whose target service is unreachable: web
// templates on hosts that do not speak HTTP and network templates on closed
// ports. It is the SDK form of -strict-probe.
func EnableStrictProbe() NucleiSDKOptions {
return func(e *NucleiEngine) error {
e.opts.StrictProbe = true
return nil
}
}
// EnableTechFilter fingerprints HTTP targets and skips product-bound templates
// that cannot match them. It also turns on the response cache, which the
// fingerprint requests seed. It is the SDK form of -tech-filter.
func EnableTechFilter() NucleiSDKOptions {
return func(e *NucleiEngine) error {
e.opts.TechFilter = true
return nil
}
}
-8
View File
@@ -5,7 +5,6 @@ import (
"time"
"github.com/logrusorgru/aurora/v4"
"github.com/projectdiscovery/nuclei/v3/internal/runner"
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
"github.com/projectdiscovery/nuclei/v3/pkg/core"
@@ -13,7 +12,6 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/loader/workflow"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httprespcache"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/nuclei/v3/pkg/utils"
"github.com/projectdiscovery/utils/errkit"
@@ -62,11 +60,6 @@ func createEphemeralObjects(ctx context.Context, base *NucleiEngine, opts *types
opts.RateLimitDuration = time.Second
}
u.executerOpts.RateLimiter = utils.GetRateLimiter(ctx, opts.RateLimit, opts.RateLimitDuration)
// Each execution gets its own response cache, created before its
// templates compile.
if opts.UsesResponseCache() && u.executerOpts.HTTPResponseCache == nil {
u.executerOpts.HTTPResponseCache = httprespcache.New()
}
return u, nil
}
@@ -247,7 +240,6 @@ func (e *ThreadSafeNucleiEngine) ExecuteNucleiWithOptsCtx(ctx context.Context, t
engine := core.New(tmpEngine.opts)
engine.SetExecuterOptions(unsafeOpts.executerOpts)
defer runner.AttachScanPlan(tmpEngine.opts, inputProvider, tmpEngine.opts.Logger, engine, store.Templates())()
_ = engine.ExecuteScanWithOpts(ctx, store.Templates(), inputProvider, false)
engine.WorkPool().Wait()
-120
View File
@@ -1,120 +0,0 @@
package nuclei_test
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
nuclei "github.com/projectdiscovery/nuclei/v3/lib"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/stretchr/testify/require"
)
func TestScanPlanOptions(t *testing.T) {
ne, err := nuclei.NewNucleiEngineCtx(context.Background(),
nuclei.DisableUpdateCheck(),
nuclei.EnableStrictProbe(),
nuclei.EnableTechFilter(),
nuclei.EnableResponseCache(),
)
require.NoError(t, err)
defer ne.Close()
opts := ne.Options()
require.True(t, opts.StrictProbe)
require.True(t, opts.TechFilter)
require.True(t, opts.ResponseCache)
}
// sdkCacheTemplates writes two templates that both GET /same. The second also
// GETs /other, so request clustering cannot merge them into one request.
func sdkCacheTemplates(t *testing.T) []string {
t.Helper()
dir := t.TempDir()
a := `id: sdk-cache-a
info:
name: SDK Cache A
author: test
severity: info
http:
- method: GET
path:
- "{{BaseURL}}/same"
matchers:
- type: word
words:
- "cached-body"
`
b := `id: sdk-cache-b
info:
name: SDK Cache B
author: test
severity: info
http:
- method: GET
path:
- "{{BaseURL}}/same"
- "{{BaseURL}}/other"
matchers:
- type: word
words:
- "cached-body"
`
pa, pb := filepath.Join(dir, "a.yaml"), filepath.Join(dir, "b.yaml")
require.NoError(t, os.WriteFile(pa, []byte(a), 0o600))
require.NoError(t, os.WriteFile(pb, []byte(b), 0o600))
return []string{pa, pb}
}
// With the SDK options for the scan plan set, both templates still run and
// match, and the response cache sends /same once. Without them it is sent
// twice.
func TestSDKScanPlanAndResponseCache(t *testing.T) {
var sameHits atomic.Int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/same" {
sameHits.Add(1)
time.Sleep(200 * time.Millisecond)
}
_, _ = w.Write([]byte("cached-body"))
}))
defer ts.Close()
tpls := sdkCacheTemplates(t)
run := func(extra ...nuclei.NucleiSDKOptions) map[string]bool {
sameHits.Store(0)
opts := append([]nuclei.NucleiSDKOptions{
nuclei.DisableUpdateCheck(),
nuclei.WithTemplatesOrWorkflows(nuclei.TemplateSources{Templates: tpls}),
}, extra...)
ne, err := nuclei.NewNucleiEngineCtx(context.Background(), opts...)
require.NoError(t, err)
defer ne.Close()
ne.Options().RestrictLocalNetworkAccess = false
ne.Options().Retries = 0
ne.LoadTargets([]string{ts.URL}, false)
var mu sync.Mutex
matched := map[string]bool{}
require.NoError(t, ne.ExecuteCallbackWithCtx(context.Background(), func(event *output.ResultEvent) {
mu.Lock()
defer mu.Unlock()
matched[event.TemplateID] = true
}))
return matched
}
matched := run(nuclei.EnableResponseCache(), nuclei.EnableStrictProbe(), nuclei.EnableTechFilter())
require.Equal(t, map[string]bool{"sdk-cache-a": true, "sdk-cache-b": true}, matched)
require.Equal(t, int32(1), sameHits.Load(), "the response cache must send /same once")
matched = run()
require.Equal(t, map[string]bool{"sdk-cache-a": true, "sdk-cache-b": true}, matched)
require.Equal(t, int32(2), sameHits.Load(), "without the cache /same is sent by each template")
}
-5
View File
@@ -9,7 +9,6 @@ import (
"sync"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/internal/runner"
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog"
metadataindex "github.com/projectdiscovery/nuclei/v3/pkg/catalog/index"
@@ -332,10 +331,6 @@ func (e *NucleiEngine) ExecuteCallbackWithCtx(ctx context.Context, callback ...f
wg.Add(1)
go func() {
defer wg.Done()
// A response cached by an earlier scan of this engine is never served
// to this one.
e.executerOpts.HTTPResponseCache.Reset()
defer runner.AttachScanPlan(e.opts, e.inputProvider, e.opts.Logger, e.engine, templatesAndWorkflows)()
_ = e.engine.ExecuteScanWithOpts(ctx, templatesAndWorkflows, e.inputProvider, false)
}()
+3 -7
View File
@@ -33,7 +33,6 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/headless/engine"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httpclientpool"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httprespcache"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
nucleiUtils "github.com/projectdiscovery/nuclei/v3/pkg/utils"
@@ -354,17 +353,14 @@ func (e *NucleiEngine) init(ctx context.Context) error {
e.executerOpts.Options.Parser = op
}
// Templates compile against e.executerOpts, so the response cache must
// exist before they load. Each scan resets it.
if e.opts.UsesResponseCache() && e.executerOpts.HTTPResponseCache == nil {
e.executerOpts.HTTPResponseCache = httprespcache.New()
}
e.engine = core.New(e.opts)
e.engine.SetExecuterOptions(e.executerOpts)
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 {
+12 -4
View File
@@ -7,18 +7,22 @@ import (
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/alecthomas/chroma/quick"
"github.com/alecthomas/chroma/v2/quick"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/retryablehttp-go"
pdcpauth "github.com/projectdiscovery/utils/auth/pdcp"
"github.com/projectdiscovery/utils/errkit"
)
const (
var (
aiTemplateGeneratorAPIEndpoint = "https://api.projectdiscovery.io/v1/template/ai"
// aiTemplateHTTPClient verifies TLS, since the request carries the PDCP API key.
aiTemplateHTTPClient = &http.Client{}
// aiTemplateIDRegex keeps the template file name inside the pdcp directory.
aiTemplateIDRegex = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
)
type AITemplateResponse struct {
@@ -112,7 +116,7 @@ func generateAITemplate(prompt string) (string, string, error) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set(pdcpauth.ApiKeyHeaderName, creds.APIKey)
resp, err := retryablehttp.DefaultClient().Do(req)
resp, err := aiTemplateHTTPClient.Do(req)
if err != nil {
return "", "", errkit.Newf("Failed to send HTTP request: %v", err)
}
@@ -138,5 +142,9 @@ func generateAITemplate(prompt string) (string, string, error) {
return "", "", errkit.Newf("Failed to generate template")
}
if !aiTemplateIDRegex.MatchString(result.TemplateID) {
return "", "", errkit.Newf("Invalid template ID in API response: %q", result.TemplateID)
}
return result.Completion, result.TemplateID, nil
}
+55
View File
@@ -0,0 +1,55 @@
package loader
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
)
func newAITemplateServer(t *testing.T, templateID string, gotKey *atomic.Value) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotKey.Store(r.Header.Get("X-Api-Key"))
_ = json.NewEncoder(w).Encode(map[string]string{
"completion": "id: test\n",
"template_id": templateID,
})
}))
t.Cleanup(srv.Close)
oldEndpoint, oldClient := aiTemplateGeneratorAPIEndpoint, aiTemplateHTTPClient
t.Cleanup(func() { aiTemplateGeneratorAPIEndpoint, aiTemplateHTTPClient = oldEndpoint, oldClient })
aiTemplateGeneratorAPIEndpoint = srv.URL
t.Setenv("PDCP_API_KEY", "test-key")
if templateID != "" {
// Trust the test certificate, to check the template_id only.
aiTemplateHTTPClient = srv.Client()
}
}
func TestAITemplateVerifiesTLS(t *testing.T) {
var gotKey atomic.Value
newAITemplateServer(t, "", &gotKey)
_, _, err := generateAITemplate("check for exposed admin panels")
require.ErrorContains(t, err, "certificate")
require.Nil(t, gotKey.Load(), "the API key went to an untrusted server")
}
func TestAITemplateRejectsUnsafeID(t *testing.T) {
for _, id := range []string{"../../.config/nuclei/config", "a/b", `a\b`, "a.b", "a b"} {
var gotKey atomic.Value
newAITemplateServer(t, id, &gotKey)
_, _, err := generateAITemplate("check for exposed admin panels")
require.Error(t, err, id)
}
var gotKey atomic.Value
newAITemplateServer(t, "Abc-123_x", &gotKey)
_, templateID, err := generateAITemplate("check for exposed admin panels")
require.NoError(t, err)
require.Equal(t, "Abc-123_x", templateID)
}
+10 -4
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)
store.logErroredTemplates(errs)
notFound := store.logErroredTemplates(errs)
workflowPaths, errs := store.config.Catalog.GetTemplatesPath(store.finalWorkflows)
store.logErroredTemplates(errs)
notFound += store.logErroredTemplates(errs)
templatePathsMap := make(map[string]struct{}, len(templatePaths))
for _, path := range templatePaths {
@@ -646,7 +646,8 @@ func (store *Store) ValidateTemplates() error {
workflowPathsMap[path] = struct{}{}
}
if store.areTemplatesValid(templatePathsMap) && store.areWorkflowsValid(workflowPathsMap) {
// 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 {
return nil
}
@@ -1056,10 +1057,15 @@ func workflowContainsProtocol(workflow []*workflows.WorkflowTemplate) bool {
return false
}
func (s *Store) logErroredTemplates(erred map[string]error) {
// 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
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
}
+43 -20
View File
@@ -176,25 +176,8 @@ func TestLoadTemplatesOnlyMetadataLogsCachedTemplateParseErrors(t *testing.T) {
require.Contains(t, output.String(), "Could not load template")
}
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))
func newValidateStore(t *testing.T, executionID, templatePath string) (*Store, *bytes.Buffer) {
t.Helper()
options := testutils.DefaultOptions.Copy()
var output bytes.Buffer
@@ -203,7 +186,7 @@ http:
logger.SetWriter(&utils.CaptureWriter{Buffer: &output})
logger.SetMaxLevel(levels.LevelDebug)
options.Logger = logger
options.ExecutionId = "loader-invalid-regex-group"
options.ExecutionId = executionID
options.Templates = []string{templatePath}
options.Validate = true
options.TemplateLoadingConcurrency = 1
@@ -226,10 +209,50 @@ http:
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("")
+2 -1
View File
@@ -86,7 +86,8 @@ func (e *Engine) ExecuteScanWithOpts(ctx context.Context, templatesList []*templ
}
if stringsutil.EqualFoldAny(e.options.ScanStrategy, scanstrategy.Auto.String(), "") {
// Last-resort fallback: runner scan planner normally resolves auto.
// TODO: this is only a placeholder, auto scan strategy should choose scan strategy
// based on no of hosts , templates , stream and other optimization parameters
e.options.ScanStrategy = scanstrategy.TemplateSpray.String()
}
-165
View File
@@ -1,165 +0,0 @@
package plan
import (
"fmt"
"github.com/projectdiscovery/nuclei/v3/pkg/types/scanstrategy"
)
// Input is the scan shape the planner uses to pick strategy and reachability.
type Input struct {
Hosts int
Templates int
Requests int // template×host request estimate (baseline)
BulkSize int
Stream bool
StrictProbe bool
// Reachability / probe budget (known before or after probing).
PortsToProbe int
ConcreteNetworkTemplates int
// FilteredRequests, when > 0, is the post-filter request estimate the plan reports.
FilteredRequests int
// GroupCount is distinct host signatures after probing (0 = unknown).
GroupCount int
// TechFilter enables product/macro tag filtering after cheap HTTP fingerprinting.
TechFilter bool
// TechBoundTemplates is how many templates carry product/macro tags.
TechBoundTemplates int
}
// Plan is the execution plan for a single scan pass.
type Plan struct {
Strategy string
BuildReachability bool
ProbePorts bool
UseReachabilityFilter bool
UseTechFilter bool
ExpectedRequests int64
Reason string
}
// Decide chooses spray strategy and whether to spend budget on reachability probing.
//
// Hard bar: never choose work that is expected to regress wall clock vs baseline spray.
// When probe cost would dominate and savings are uncertain, reachability is skipped.
func Decide(in Input) Plan {
p := Plan{
Strategy: chooseStrategy(in),
ExpectedRequests: int64(max(in.Requests, 0)),
}
reachReason := "reachability=off"
if in.StrictProbe {
reach := decideReachability(in)
p.BuildReachability = reach.build
p.ProbePorts = reach.probePorts
p.UseReachabilityFilter = reach.useFilter
reachReason = reach.reason
}
tech := decideTechFilter(in)
p.UseTechFilter = tech.use
p.Reason = strategyReason(in, p.Strategy) + "; " + reachReason + "; " + tech.reason
if in.FilteredRequests > 0 {
p.ExpectedRequests = int64(in.FilteredRequests)
}
return p
}
func chooseStrategy(in Input) string {
if in.Stream || in.Hosts <= 0 || in.Templates <= 0 {
return scanstrategy.TemplateSpray.String()
}
bulk := in.BulkSize
if bulk <= 0 {
bulk = 25
}
// Host-spray only when the shape strongly favors per-host locality.
// Conservative default is template-spray (historical auto) so wall clock
// does not regress on small / mixed benches.
if in.Hosts <= bulk && in.Hosts <= 10 && in.Templates >= 10*in.Hosts && in.Requests >= 5000 {
return scanstrategy.HostSpray.String()
}
return scanstrategy.TemplateSpray.String()
}
func strategyReason(in Input, strategy string) string {
return fmt.Sprintf("strategy=%s hosts=%d templates=%d", strategy, in.Hosts, in.Templates)
}
type reachabilityDecision struct {
build bool
probePorts bool
useFilter bool
reason string
}
func decideReachability(in Input) reachabilityDecision {
if in.Hosts <= 0 || in.Templates <= 0 {
return reachabilityDecision{reason: "reachability=skipped-empty"}
}
probeCost := in.PortsToProbe * in.Hosts
baseline := in.Requests
if baseline <= 0 {
baseline = in.Templates * in.Hosts
}
// No concrete network ports to probe: HTTP-only index is cheap (reuse httpx map).
if in.PortsToProbe == 0 || in.ConcreteNetworkTemplates == 0 {
return reachabilityDecision{
build: true,
probePorts: false,
useFilter: true,
reason: "reachability=http-only",
}
}
// Probe budget: if dialing every port×host costs more than the scan itself
// and we have no signal of a mixed fleet, skip probing to match baseline time.
if probeCost > baseline && in.GroupCount <= 1 {
// GroupCount==0 (unknown) or 1 (homogeneous): do not pay for probes.
if probeCost > max(baseline*2, 500) {
return reachabilityDecision{
reason: fmt.Sprintf("reachability=skipped-probe-budget cost=%d baseline=%d", probeCost, baseline),
}
}
}
// After probing, homogeneous fleets still benefit from a cheap filter (no extra dials
// during spray for closed-port templates that global prune may have kept).
return reachabilityDecision{
build: true,
probePorts: true,
useFilter: true,
reason: fmt.Sprintf("reachability=full probe_cost=%d", probeCost),
}
}
type techFilterDecision struct {
use bool
reason string
}
func decideTechFilter(in Input) techFilterDecision {
// Opt-in only: never enable from strategy/reachability alone.
if !in.TechFilter {
return techFilterDecision{reason: "tech-filter=off"}
}
// Skip fingerprinting when nothing is tech-bound — otherwise we add
// probe latency with zero savings (wall-clock regression).
if in.TechBoundTemplates <= 0 {
return techFilterDecision{reason: "tech-filter=skipped-no-bound-templates"}
}
return techFilterDecision{use: true, reason: fmt.Sprintf("tech-filter=on bound=%d", in.TechBoundTemplates)}
}
// ApplyFiltered updates ExpectedRequests once post-probe estimates are known.
func (p *Plan) ApplyFiltered(filtered int) {
if filtered > 0 {
p.ExpectedRequests = int64(filtered)
}
}
-205
View File
@@ -1,205 +0,0 @@
package plan
import (
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/types/scanstrategy"
"github.com/stretchr/testify/require"
)
func TestDecideStrategyHostSpray(t *testing.T) {
p := Decide(Input{
Hosts: 5,
Templates: 200,
Requests: 10000,
BulkSize: 25,
})
require.Equal(t, scanstrategy.HostSpray.String(), p.Strategy)
require.Contains(t, p.Reason, "strategy=host-spray")
}
func TestDecideStrategyTemplateSpraySmallBench(t *testing.T) {
p := Decide(Input{
Hosts: 5,
Templates: 100,
Requests: 500,
BulkSize: 25,
})
require.Equal(t, scanstrategy.TemplateSpray.String(), p.Strategy)
}
func TestDecideStrategyTemplateSprayManyHosts(t *testing.T) {
p := Decide(Input{
Hosts: 1000,
Templates: 50,
Requests: 50000,
BulkSize: 25,
})
require.Equal(t, scanstrategy.TemplateSpray.String(), p.Strategy)
}
func TestDecideStrategyStreamDefaultsTemplateSpray(t *testing.T) {
p := Decide(Input{
Hosts: 5,
Templates: 100,
Stream: true,
BulkSize: 25,
})
require.Equal(t, scanstrategy.TemplateSpray.String(), p.Strategy)
}
func TestDecideReachabilityOffWithoutStrictProbe(t *testing.T) {
p := Decide(Input{
Hosts: 5,
Templates: 60,
Requests: 300,
StrictProbe: false,
PortsToProbe: 3,
})
require.False(t, p.BuildReachability)
require.False(t, p.UseReachabilityFilter)
require.Contains(t, p.Reason, "reachability=off")
}
func TestDecideReachabilityHTTPOnly(t *testing.T) {
p := Decide(Input{
Hosts: 5,
Templates: 60,
Requests: 300,
StrictProbe: true,
PortsToProbe: 0,
ConcreteNetworkTemplates: 0,
})
require.True(t, p.BuildReachability)
require.False(t, p.ProbePorts)
require.True(t, p.UseReachabilityFilter)
require.Contains(t, p.Reason, "reachability=http-only")
}
func TestDecideReachabilityProbeBudgetSkip(t *testing.T) {
p := Decide(Input{
Hosts: 100,
Templates: 10,
Requests: 1000,
StrictProbe: true,
PortsToProbe: 50, // cost = 5000 > 2*baseline
ConcreteNetworkTemplates: 5,
GroupCount: 0,
})
require.False(t, p.BuildReachability)
require.False(t, p.UseReachabilityFilter)
require.Contains(t, p.Reason, "reachability=skipped-probe-budget")
}
func TestDecideReachabilityProbeWithinBudget(t *testing.T) {
p := Decide(Input{
Hosts: 100,
Templates: 10,
Requests: 1000,
StrictProbe: true,
PortsToProbe: 15, // cost = 1500: above baseline, within 2*baseline
ConcreteNetworkTemplates: 5,
GroupCount: 0,
})
require.True(t, p.BuildReachability)
require.True(t, p.ProbePorts)
require.True(t, p.UseReachabilityFilter)
require.Contains(t, p.Reason, "reachability=full")
}
func TestDecideReachabilityFull(t *testing.T) {
p := Decide(Input{
Hosts: 5,
Templates: 60,
Requests: 300,
StrictProbe: true,
PortsToProbe: 3, // cost = 15 << baseline
ConcreteNetworkTemplates: 30,
})
require.True(t, p.BuildReachability)
require.True(t, p.ProbePorts)
require.True(t, p.UseReachabilityFilter)
require.Contains(t, p.Reason, "reachability=full")
}
func TestDecideReachabilityEmptyInput(t *testing.T) {
p := Decide(Input{StrictProbe: true, Hosts: 0, Templates: 10})
require.False(t, p.BuildReachability)
require.Contains(t, p.Reason, "reachability=skipped-empty")
}
func TestDecideReachabilityConcreteZeroUsesHTTPOnly(t *testing.T) {
p := Decide(Input{
Hosts: 20,
Templates: 100,
Requests: 2000,
StrictProbe: true,
PortsToProbe: 5,
ConcreteNetworkTemplates: 0, // web-only set
})
require.True(t, p.BuildReachability)
require.False(t, p.ProbePorts)
require.True(t, p.UseReachabilityFilter)
}
func TestDecideHostSprayRequiresLargeRequestBudget(t *testing.T) {
// hosts small + many templates but requests below threshold → template-spray
p := Decide(Input{
Hosts: 5,
Templates: 200,
Requests: 4999,
BulkSize: 25,
})
require.Equal(t, scanstrategy.TemplateSpray.String(), p.Strategy)
}
func TestDecideApplyFilteredIgnoredWhenZero(t *testing.T) {
p := Decide(Input{
Hosts: 5,
Templates: 10,
Requests: 50,
StrictProbe: false,
FilteredRequests: 0,
})
require.Equal(t, int64(50), p.ExpectedRequests)
p.ApplyFiltered(0)
require.Equal(t, int64(50), p.ExpectedRequests)
p.ApplyFiltered(12)
require.Equal(t, int64(12), p.ExpectedRequests)
}
func TestDecideTechFilterOffByDefault(t *testing.T) {
p := Decide(Input{
Hosts: 5,
Templates: 100,
Requests: 500,
TechFilter: false,
TechBoundTemplates: 40,
})
require.False(t, p.UseTechFilter)
require.Contains(t, p.Reason, "tech-filter=off")
}
func TestDecideTechFilterSkippedWhenNoBound(t *testing.T) {
p := Decide(Input{
Hosts: 5,
Templates: 100,
Requests: 500,
TechFilter: true,
TechBoundTemplates: 0,
})
require.False(t, p.UseTechFilter)
require.Contains(t, p.Reason, "tech-filter=skipped-no-bound-templates")
}
func TestDecideTechFilterOn(t *testing.T) {
p := Decide(Input{
Hosts: 5,
Templates: 100,
Requests: 500,
TechFilter: true,
TechBoundTemplates: 40,
})
require.True(t, p.UseTechFilter)
require.Contains(t, p.Reason, "tech-filter=on")
}
-343
View File
@@ -1,343 +0,0 @@
// Package techfilter maps Wappalyzer products/categories to template tags and
// decides whether a template can produce a finding on a fingerprinted host.
//
// Fail-open rules (coverage over aggressiveness):
// - templates with no product/macro tags always run
// - hosts with no fingerprints always allow every template
// - CDN/WAF-only fingerprints do not suppress product templates
// - non-HTTP templates are not filtered here (reachability owns that)
package techfilter
import (
"encoding/json"
"strings"
"sync"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
wappalyzer "github.com/projectdiscovery/wappalyzergo"
)
// genericTags are common nuclei tags that do not bind a template to a product.
var genericTags = map[string]struct{}{
"cve": {}, "cnvd": {}, "edb": {}, "kev": {}, "vkev": {},
"misconfig": {}, "exposure": {}, "config": {}, "disclosure": {},
"tech": {}, "detect": {}, "fingerprint": {}, "favicon": {}, "waf": {},
"http": {}, "network": {}, "dns": {}, "ssl": {}, "tls": {},
"websocket": {}, "headless": {}, "file": {}, "code": {}, "javascript": {},
"workflow": {}, "intrusive": {}, "dos": {}, "fuzz": {}, "token-spray": {},
"osint": {}, "panel": {}, "login": {}, "auth": {}, "unauth": {},
"default-login": {}, "vuln": {}, "rce": {}, "lfi": {}, "sqli": {},
"xss": {}, "ssrf": {}, "xxe": {}, "ssti": {}, "idor": {},
"critical": {}, "high": {}, "medium": {}, "low": {}, "info": {},
"unknown": {}, "generic": {}, "misc": {}, "bench": {},
// Meta / source / taxonomy tags that are not products.
"oast": {}, "takeover": {}, "deserialization": {}, "packetstorm": {},
"seclists": {}, "hackerone": {}, "huntr": {}, "wp-plugin": {}, "wp-theme": {},
"wordpress-plugin": {}, "wordpress-theme": {}, "authenticated": {},
"unauthenticated": {}, "bypass": {}, "injection": {}, "traversal": {},
"redirect": {}, "crlf": {}, "csrf": {}, "cors": {},
}
// weakFingerprintTags alone must not suppress product-bound templates
// (e.g. cloudflare/cdn-only fingerprints).
var weakFingerprintTags = map[string]struct{}{
"cdn": {}, "waf": {}, "misc": {}, "security": {}, "caching": {},
}
// productAliases normalize common nuclei / wappalyzer spellings to one token.
var productAliases = map[string]string{
"wp": "wordpress",
"httpd": "apache",
"apache-http-server": "apache",
}
// categoryMacros maps Wappalyzer category names to short macro tags.
var categoryMacros = map[string]string{
"cms": "cms",
"ecommerce": "ecommerce",
"web servers": "webserver",
"web-servers": "webserver",
"web frameworks": "web-framework",
"web-frameworks": "web-framework",
"javascript frameworks": "js-framework",
"javascript-frameworks": "js-framework",
"javascript libraries": "js-library",
"javascript-libraries": "js-library",
"cdn": "cdn",
"databases": "database",
"programming languages": "language",
"programming-languages": "language",
"paas": "paas",
"saas": "saas",
"security": "security",
"caching": "caching",
"miscellaneous": "misc",
}
// HostProfile is the set of product + macro tags inferred for one target.
type HostProfile struct {
Tags map[string]struct{} // empty / nil => unknown => fail-open
// Cats are the Wappalyzer category ids of every detected product. They
// record what the fingerprint actually has evidence about, which is what
// makes it safe to rule a template out. See AllowTags.
Cats map[int]struct{}
}
// HasTags reports whether fingerprinting produced at least one tag.
func (h HostProfile) HasTags() bool {
return len(h.Tags) > 0
}
// HasSubstantiveTags reports whether the profile has a strong category macro
// (cms, webserver, …). CDN/WAF-only fingerprints are not substantive.
func (h HostProfile) HasSubstantiveTags() bool {
for tag := range h.Tags {
if isStrongMacro(tag) {
return true
}
}
return false
}
func isStrongMacro(tag string) bool {
if _, weak := weakFingerprintTags[tag]; weak {
return false
}
for _, m := range categoryMacros {
if tag == m {
return true
}
}
return false
}
// NormalizeProduct turns a Wappalyzer / nuclei product name into a tag token.
func NormalizeProduct(name string) string {
name = strings.TrimSpace(strings.ToLower(name))
if name == "" {
return ""
}
if i := strings.IndexByte(name, ':'); i > 0 {
name = name[:i]
}
name = strings.ReplaceAll(name, "_", "-")
name = strings.Join(strings.Fields(name), "-")
if alias, ok := productAliases[name]; ok {
return alias
}
return name
}
// NormalizeCategory turns a Wappalyzer category into a macro tag.
func NormalizeCategory(cat string) string {
key := strings.ToLower(strings.TrimSpace(cat))
if key == "" {
return ""
}
if m, ok := categoryMacros[key]; ok {
return m
}
return strings.ReplaceAll(key, " ", "-")
}
// IsGenericTag reports whether tag is not product/macro binding.
func IsGenericTag(tag string) bool {
tag = strings.ToLower(strings.TrimSpace(tag))
if tag == "" {
return true
}
if _, ok := genericTags[tag]; ok {
return true
}
// cve2021 / cve-2021 style year tags bind no product.
if strings.HasPrefix(tag, "cve") {
rest := strings.TrimPrefix(tag, "cve")
rest = strings.TrimPrefix(rest, "-")
if rest != "" && isAllDigits(rest) {
return true
}
}
return false
}
func isAllDigits(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
// ProfileFromFingerprint builds a host profile from products and their categories.
// products maps product name -> category names from Wappalyzer.
func ProfileFromFingerprint(products map[string][]string) HostProfile {
tags := make(map[string]struct{})
observed := make(map[int]struct{})
byProduct := productCategories()
for product, cats := range products {
if p := NormalizeProduct(product); p != "" {
tags[p] = struct{}{}
for _, id := range byProduct[p] {
observed[id] = struct{}{}
}
}
for _, c := range cats {
if m := NormalizeCategory(c); m != "" {
tags[m] = struct{}{}
}
}
}
return HostProfile{Tags: tags, Cats: observed}
}
// TemplateProductTags returns product/macro tags that bind a template to a stack.
// Empty means the template is generic and must always be allowed.
func TemplateProductTags(t *templates.Template) []string {
if t == nil {
return nil
}
raw := t.Info.Tags.ToSlice()
out := make([]string, 0, len(raw))
seen := make(map[string]struct{}, len(raw))
known := knownFingerprintTokens()
for _, tag := range raw {
tag = NormalizeProduct(tag)
if tag == "" || IsGenericTag(tag) {
continue
}
// A tag binds only when fingerprinting can actually emit it. Anything
// else would never match a profile and would prune the template on
// every host, so it must leave the template unbound.
if _, ok := known[tag]; !ok {
continue
}
if _, ok := seen[tag]; ok {
continue
}
seen[tag] = struct{}{}
out = append(out, tag)
}
return out
}
// knownFingerprintTokens is every token a host profile can contain: the
// normalized name of every Wappalyzer app plus every category macro.
//
// This is an allowlist on purpose. nuclei-templates carries thousands of tags
// that name no product at all (discovery, wpscan, top-200, auth-bypass, ...).
// Treating an unrecognized tag as a product binds the template to something no
// fingerprint can ever report, which silently drops it on every host. Unknown
// tags must therefore bind nothing.
var knownFingerprintTokens = sync.OnceValue(func() map[string]struct{} {
var data struct {
Apps map[string]json.RawMessage `json:"apps"`
}
tokens := make(map[string]struct{}, 8192)
if err := json.Unmarshal([]byte(wappalyzer.GetFingerprints()), &data); err == nil {
for name := range data.Apps {
if p := NormalizeProduct(name); p != "" {
tokens[p] = struct{}{}
}
}
}
for _, macro := range categoryMacros {
tokens[macro] = struct{}{}
}
for tag := range weakFingerprintTags {
tokens[tag] = struct{}{}
}
return tokens
})
// IsTechBound reports whether the template is constrained to a product/macro.
func IsTechBound(t *templates.Template) bool {
return len(TemplateProductTags(t)) > 0
}
// Allow reports whether template may run on a host with the given profile.
// Fail-open when the host was not fingerprinted or the template is unbound.
func Allow(profile HostProfile, t *templates.Template) bool {
return AllowTags(profile, TemplateProductTags(t))
}
// AllowTags is Allow with precomputed product tags (avoids per-pair allocation).
func AllowTags(profile HostProfile, bound []string) bool {
if len(bound) == 0 {
return true
}
if !profile.HasTags() || !profile.HasSubstantiveTags() {
return true
}
for _, tag := range bound {
if _, ok := profile.Tags[tag]; ok {
return true
}
}
// No positive match. Ruling the template out now would assume the
// fingerprint is complete, and it never is: it is one page of one host, and
// a stack is routinely invisible from it (WordPress behind nginx is the
// ordinary case, not the exception). Absence of evidence is not evidence of
// absence, so a template is dropped only when the fingerprint positively
// contradicts it: something else was identified in the same category, and
// the template's own product was not. If any bound product sits in a
// category the fingerprint says nothing about, the template runs.
byProduct := productCategories()
for _, tag := range bound {
cats, ok := byProduct[tag]
if !ok || len(cats) == 0 {
return true // category macro or unknown product: no contradiction possible
}
contradicted := false
for _, id := range cats {
if _, seen := profile.Cats[id]; seen {
contradicted = true
break
}
}
if !contradicted {
return true
}
}
return false
}
// productCategories maps a normalized product token to its Wappalyzer category
// ids. It is the evidence index: a category id present in a host profile means
// fingerprinting actually identified something of that kind on that host.
var productCategories = sync.OnceValue(func() map[string][]int {
var data struct {
Apps map[string]struct {
Cats []int `json:"cats"`
} `json:"apps"`
}
out := make(map[string][]int, 8192)
if err := json.Unmarshal([]byte(wappalyzer.GetFingerprints()), &data); err != nil {
return out
}
for name, app := range data.Apps {
p := NormalizeProduct(name)
if p == "" || len(app.Cats) == 0 {
continue
}
// Aliases collapse several apps onto one token; union their categories.
out[p] = append(out[p], app.Cats...)
}
return out
})
// CountTechBound returns how many templates carry product/macro tags.
func CountTechBound(tpls []*templates.Template) int {
n := 0
for _, t := range tpls {
if IsTechBound(t) {
n++
}
}
return n
}
-208
View File
@@ -1,208 +0,0 @@
package techfilter
import (
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/model"
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/stringslice"
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
)
func TestNormalizeProductAndCategory(t *testing.T) {
if got := NormalizeProduct("Nginx:1.25"); got != "nginx" {
t.Fatalf("NormalizeProduct = %q", got)
}
if got := NormalizeProduct("wp"); got != "wordpress" {
t.Fatalf("alias = %q", got)
}
if got := NormalizeCategory("Web servers"); got != "webserver" {
t.Fatalf("category = %q", got)
}
}
func TestProfileFromFingerprint(t *testing.T) {
p := ProfileFromFingerprint(map[string][]string{
"Nginx": {"Web servers"},
"WordPress": {"CMS"},
})
for _, want := range []string{"nginx", "webserver", "wordpress", "cms"} {
if _, ok := p.Tags[want]; !ok {
t.Fatalf("missing tag %q in %#v", want, p.Tags)
}
}
}
func tagged(tags ...string) *templates.Template {
return &templates.Template{
Info: model.Info{Tags: stringslice.StringSlice{Value: tags}},
}
}
func TestAllowFailOpenAndMatch(t *testing.T) {
nginx := ProfileFromFingerprint(map[string][]string{"Nginx": {"Web servers"}})
empty := HostProfile{}
generic := tagged("misconfig", "http")
wp := tagged("cve", "wordpress")
ngx := tagged("nginx", "misconfig")
cms := tagged("cms")
if !Allow(nginx, generic) {
t.Fatal("generic must always run")
}
if !Allow(empty, wp) {
t.Fatal("unknown host must fail-open for bound templates")
}
// An nginx fingerprint says nothing about the CMS behind it, and WordPress
// behind nginx is an ordinary stack. Absence of a CMS in the profile is not
// evidence that there is none.
if !Allow(nginx, wp) {
t.Fatal("nginx says nothing about a CMS, so a wordpress template must run")
}
if !Allow(nginx, ngx) {
t.Fatal("nginx template must run on nginx host")
}
if !Allow(nginx, cms) {
t.Fatal("nginx says nothing about a CMS, so a cms-bound template must run")
}
wpHost := ProfileFromFingerprint(map[string][]string{"WordPress": {"CMS"}})
if !Allow(wpHost, cms) {
t.Fatal("cms macro must match wordpress host")
}
if !Allow(wpHost, wp) {
t.Fatal("wordpress product must match")
}
}
func TestMetaTagsAreNotTechBound(t *testing.T) {
cases := [][]string{
{"cve", "cve2021", "oast", "rce"},
{"takeover", "dns"},
{"hackerone", "packetstorm", "seclists"},
{"wp-plugin", "wordpress-plugin"},
}
for _, tags := range cases {
tpl := tagged(tags...)
if IsTechBound(tpl) {
t.Fatalf("tags %v must not be tech-bound, got %v", tags, TemplateProductTags(tpl))
}
cdnOnly := ProfileFromFingerprint(map[string][]string{"Cloudflare": {"CDN"}})
if !Allow(cdnOnly, tpl) {
t.Fatalf("unbound meta tags %v must run on any host", tags)
}
}
}
func TestCDNOnlyFingerprintFailOpen(t *testing.T) {
cdnOnly := ProfileFromFingerprint(map[string][]string{"Cloudflare": {"CDN"}})
if cdnOnly.HasSubstantiveTags() {
t.Fatalf("cdn-only profile should not be substantive: %#v", cdnOnly.Tags)
}
wp := tagged("wordpress")
if !Allow(cdnOnly, wp) {
t.Fatal("CDN-only fingerprint must fail-open for product templates")
}
}
func TestCountTechBound(t *testing.T) {
n := CountTechBound([]*templates.Template{
tagged("misconfig"),
tagged("wordpress"),
tagged("nginx", "http"),
tagged("cve2023", "oast"),
})
if n != 2 {
t.Fatalf("CountTechBound = %d", n)
}
}
// A tag only binds when fingerprinting can emit it. nuclei-templates carries
// thousands of tags that name no product; binding to one of those prunes the
// template on every host, because no profile can ever contain it.
func TestOnlyKnownFingerprintTokensBind(t *testing.T) {
nonProducts := [][]string{
{"misconfig", "headers", "generic", "vuln"}, // http-missing-security-headers
{"tech", "discovery"}, // tech-detect
{"waf", "tech", "misc", "discovery"}, // waf-detect
{"wpscan"},
{"top-100"}, {"top-200"},
{"auth-bypass"}, {"time-based-sqli"},
{"install"}, {"files"}, {"file-upload"},
{"phishing"}, {"osint-social"},
{"debug"}, {"devops"}, {"token"}, {"tokens"}, {"fpd"}, {"oss"},
}
nginx := ProfileFromFingerprint(map[string][]string{"Nginx": {"Web servers"}})
for _, tags := range nonProducts {
tpl := tagged(tags...)
if got := TemplateProductTags(tpl); len(got) != 0 {
t.Fatalf("tags %v must not bind, got %v", tags, got)
}
if !Allow(nginx, tpl) {
t.Fatalf("unbound template %v must run on every host", tags)
}
}
}
// The inversion must not disable the filter: real products still bind, and a
// template for another stack is still pruned.
func TestKnownProductsStillBind(t *testing.T) {
for _, tags := range [][]string{{"wordpress"}, {"apache"}, {"joomla"}, {"cms"}} {
if got := TemplateProductTags(tagged(tags...)); len(got) == 0 {
t.Fatalf("tags %v name a product and must bind", tags)
}
}
wp := ProfileFromFingerprint(map[string][]string{"WordPress": {"CMS"}})
if !Allow(wp, tagged("wordpress", "rce")) {
t.Fatal("a wordpress template must run on a wordpress profile")
}
}
// A template is dropped only when the fingerprint contradicts it: it identified
// something else of the same kind and not the template's own product.
func TestPruneOnlyOnPositiveContradiction(t *testing.T) {
nginx := ProfileFromFingerprint(map[string][]string{"Nginx": {"Web servers"}})
wpHost := ProfileFromFingerprint(map[string][]string{
"WordPress": {"CMS"}, "Nginx": {"Web servers"},
})
// Contradiction: a web server was identified and it is not apache.
if Allow(nginx, tagged("apache")) {
t.Fatal("nginx was identified, so an apache template is contradicted")
}
// Contradiction: a CMS was identified and it is not joomla.
if Allow(wpHost, tagged("joomla")) {
t.Fatal("wordpress was identified, so a joomla template is contradicted")
}
// No evidence: nothing of that kind was identified at all.
for _, tags := range [][]string{{"wordpress"}, {"joomla"}, {"cms"}, {"mysql"}} {
if !Allow(nginx, tagged(tags...)) {
t.Fatalf("nginx gives no evidence about %v, so it must run", tags)
}
}
// One uncontradicted product is enough to keep a multi-product template.
if !Allow(nginx, tagged("apache", "wordpress")) {
t.Fatal("wordpress is uncontradicted, so the template must run")
}
// A positive match always wins.
if !Allow(wpHost, tagged("wordpress")) {
t.Fatal("a positive match must run")
}
}
func TestKnownFingerprintTokensVocabulary(t *testing.T) {
tokens := knownFingerprintTokens()
if len(tokens) < 5000 {
t.Fatalf("expected the wappalyzer vocabulary, got %d tokens", len(tokens))
}
for _, want := range []string{"wordpress", "nginx", "apache", "cms", "webserver"} {
if _, ok := tokens[want]; !ok {
t.Fatalf("token %q missing from the vocabulary", want)
}
}
for _, bad := range []string{"discovery", "headers", "wpscan", "top-200"} {
if _, ok := tokens[bad]; ok {
t.Fatalf("token %q is not a product and must not be in the vocabulary", bad)
}
}
}
+47 -7
View File
@@ -3,6 +3,7 @@ package core
import (
"fmt"
"net/http/cookiejar"
"sync"
"sync/atomic"
"github.com/projectdiscovery/gologger"
@@ -32,22 +33,45 @@ func (e *Engine) executeWorkflow(ctx *scan.ScanContext, w *workflows.Workflow) b
templateThreads++
}
swg, _ := syncutil.New(syncutil.WithSize(templateThreads))
// pending tracks every started step until it returns. swg only limits
// concurrency and its slots can be released early, so completion is
// tracked here instead.
var pending sync.WaitGroup
for _, template := range w.Workflows {
newCtx := scan.NewScanContext(ctx.Context(), ctx.Input.Clone())
if err := e.runWorkflowStep(template, newCtx, results, swg, w); err != nil {
// Root steps do not own a waitgroup slot.
if err := e.runWorkflowStep(template, newCtx, results, swg, &pending, w, nil); err != nil {
gologger.Warning().Msgf(workflowStepExecutionError, template.Template, err)
}
}
swg.Wait()
pending.Wait()
return results.Load()
}
// slotRelease returns a func that gives back one swg slot. It is safe to call
// from several goroutines and several times; only the first call releases.
func slotRelease(swg *syncutil.AdaptiveWaitGroup) func() {
var once sync.Once
return func() {
once.Do(swg.Done)
}
}
// runWorkflowStep runs a workflow step for the workflow. It executes the workflow
// in a recursive manner running all subtemplates and matchers.
func (e *Engine) runWorkflowStep(template *workflows.WorkflowTemplate, ctx *scan.ScanContext, results *atomic.Bool, swg *syncutil.AdaptiveWaitGroup, w *workflows.Workflow) error {
//
// releaseSlot, when non-nil, releases the AdaptiveWaitGroup slot held by the
// goroutine that is running this step. It must be called before this step
// blocks on swg.AddWithContext for child steps; otherwise every in-flight
// parent can hold a slot while waiting for another, which deadlocks when the
// number of matching nested steps reaches TemplateThreads (see #8074).
//
// pending is incremented for every child step before it starts, and each child
// calls Done when it returns, so the root can wait for the whole tree.
func (e *Engine) runWorkflowStep(template *workflows.WorkflowTemplate, ctx *scan.ScanContext, results *atomic.Bool, swg *syncutil.AdaptiveWaitGroup, pending *sync.WaitGroup, w *workflows.Workflow, releaseSlot func()) error {
var firstMatched bool
var err error
var mainErr error
@@ -131,18 +155,25 @@ func (e *Engine) runWorkflowStep(template *workflows.WorkflowTemplate, ctx *scan
continue
}
// Free this step's slot before blocking on child acquires.
if releaseSlot != nil {
releaseSlot()
}
for _, subtemplate := range matcher.Subtemplates {
if err := swg.AddWithContext(ctx.Context()); err != nil {
return
}
pending.Add(1)
go func(subtemplate *workflows.WorkflowTemplate) {
defer swg.Done()
defer pending.Done()
release := slotRelease(swg)
defer release()
// create a new context with the same input but with unset callbacks
// clone the Input so that other parallel executions won't overwrite the shared variables when subsequent templates are running
subCtx := scan.NewScanContext(ctx.Context(), ctx.Input.Clone())
if err := e.runWorkflowStep(subtemplate, subCtx, results, swg, w); err != nil {
if err := e.runWorkflowStep(subtemplate, subCtx, results, swg, pending, w, release); err != nil {
gologger.Warning().Msgf(workflowStepExecutionError, subtemplate.Template, err)
}
}(subtemplate)
@@ -162,18 +193,27 @@ func (e *Engine) runWorkflowStep(template *workflows.WorkflowTemplate, ctx *scan
return mainErr
}
if len(template.Subtemplates) > 0 && firstMatched {
// Free this step's slot before blocking on child acquires so nested
// matching steps cannot exhaust TemplateThreads and deadlock (#8074).
if releaseSlot != nil {
releaseSlot()
}
for _, subtemplate := range template.Subtemplates {
if err := swg.AddWithContext(ctx.Context()); err != nil {
return err
}
pending.Add(1)
go func(template *workflows.WorkflowTemplate) {
defer pending.Done()
release := slotRelease(swg)
defer release()
// create a new context with the same input but with unset callbacks
subCtx := scan.NewScanContext(ctx.Context(), ctx.Input.Clone())
if err := e.runWorkflowStep(template, subCtx, results, swg, w); err != nil {
if err := e.runWorkflowStep(template, subCtx, results, swg, pending, w, release); err != nil {
gologger.Warning().Msgf(workflowStepExecutionError, template.Template, err)
}
swg.Done()
}(subtemplate)
}
}
+83
View File
@@ -303,6 +303,89 @@ func TestWorkflowsSameStepExecutersRefreshTemplateContext(t *testing.T) {
require.Equal(t, "foo", secondWhoami, "could not refresh workflow context into later executers in the same step")
}
func TestWorkflowNestedSubtemplatesDoNotDeadlockAtConcurrencyLimit(t *testing.T) {
// Repro for #8074: with TemplateThreads equal to the number of matching
// nested parents, each parent used to hold a waitgroup slot while calling
// AddWithContext for its children, so every slot was occupied by a parent
// waiting for another slot and the workflow never finished.
progressBar, _ := progress.NewStatsTicker(0, false, false, false, 0)
matchOutputs := []*output.InternalWrappedEvent{{
OperatorsResult: &operators.Result{},
Results: []*output.ResultEvent{{}},
}}
const parents = 3
var entered atomic.Int32
allHolding := make(chan struct{})
releaseParents := make(chan struct{})
leafRuns := &atomic.Int32{}
leaf := &mockExecuter{result: true, executeScanHook: func(ctx *scan.ScanContext) {
leafRuns.Add(1)
}}
parentExec := &mockExecuter{
result: true,
outputs: matchOutputs,
executeScanHook: func(ctx *scan.ScanContext) {
if entered.Add(1) == parents {
close(allHolding)
}
// Stay inside ExecuteWithResults so the parent still owns its
// waitgroup slot while every peer also holds one.
<-releaseParents
},
}
mkParent := func() *workflows.WorkflowTemplate {
return &workflows.WorkflowTemplate{
Executers: []*workflows.ProtocolExecuterPair{{
Executer: parentExec,
Options: &protocols.ExecutorOptions{Progress: progressBar},
}},
Subtemplates: []*workflows.WorkflowTemplate{{
Executers: []*workflows.ProtocolExecuterPair{{
Executer: leaf,
Options: &protocols.ExecutorOptions{Progress: progressBar},
}},
}},
}
}
workflow := &workflows.Workflow{
Options: &protocols.ExecutorOptions{Options: &types.Options{TemplateThreads: parents}},
Workflows: []*workflows.WorkflowTemplate{{
Executers: []*workflows.ProtocolExecuterPair{{
Executer: &mockExecuter{result: true, outputs: matchOutputs},
Options: &protocols.ExecutorOptions{Progress: progressBar},
}},
Subtemplates: []*workflows.WorkflowTemplate{mkParent(), mkParent(), mkParent()},
}},
}
engine := &Engine{}
input := contextargs.NewWithInput(context.Background(), "https://test.com")
ctx := scan.NewScanContext(context.Background(), input)
done := make(chan bool, 1)
go func() {
done <- engine.executeWorkflow(ctx, workflow)
}()
select {
case <-allHolding:
case <-time.After(2 * time.Second):
t.Fatal("nested parents did not all acquire TemplateThreads slots")
}
close(releaseParents)
select {
case matched := <-done:
require.True(t, matched, "nested workflow should report a match")
require.EqualValues(t, parents, leafRuns.Load(), "each nested parent should run its leaf")
case <-time.After(3 * time.Second):
t.Fatal("nested workflow deadlocked at TemplateThreads == matching nested parents")
}
}
func TestWorkflowSubtemplatesUnblockCancelledAcquire(t *testing.T) {
progressBar, _ := progress.NewStatsTicker(0, false, false, false, 0)
var started atomic.Int32
+14 -18
View File
@@ -155,23 +155,14 @@ func (rule *Rule) Execute(input *ExecuteRuleInput) (err error) {
baseInteractURLs := append([]string(nil), input.InteractURLs...)
if rule.generator == nil {
for _, component := range finalComponentList {
var dataKeys map[string]struct{}
optionVars := rule.options.Options.Vars.AsMap()
constants := rule.options.Constants
dataValues := rule.options.NewVariablesScope(baseValues, optionVars, constants)
// get vars from variables while replacing interactsh urls
// Render variables once, allocating their interactsh URLs. The
// output may contain runtime data, so it is never rendered again.
evaluation := rule.options.Variables.EvaluateWithInteractshScope(dataValues, rule.options.Interactsh)
evaluatedValues, interactURLs := evaluation.Values, evaluation.InteractURLs
input.Values, dataKeys = mergeFuzzValueLayers(
fuzzValueLayer{values: evaluatedValues, kind: fuzzValueTemplate},
fuzzValueLayer{values: baseValues, kind: fuzzValueData},
fuzzValueLayer{values: optionVars, kind: fuzzValueData},
fuzzValueLayer{values: constants, kind: fuzzValueData},
)
// Replace template-text interactsh markers without evaluating runtime values.
input.Values, interactURLs = rule.evaluateVarsWithInteractsh(input.Values, interactURLs, dataKeys)
input.InteractURLs = mergeInteractURLs(baseInteractURLs, interactURLs)
input.Values = generators.MergeMaps(evaluation.Values, baseValues, optionVars, constants)
input.InteractURLs = mergeInteractURLs(baseInteractURLs, evaluation.InteractURLs)
err := rule.executeRuleValues(input, component)
if err != nil {
@@ -240,15 +231,17 @@ func (rule *Rule) prepareGeneratorValues(values, baseValues map[string]interface
evaluation := rule.options.Variables.EvaluateWithInteractshScope(evaluationValues, rule.options.Interactsh)
evaluatedValues, interactURLs := evaluation.Values, evaluation.InteractURLs
inputValues, dataKeys := mergeFuzzValueLayers(
fuzzValueLayer{values: evaluatedValues, kind: fuzzValueData},
fuzzValueLayer{values: values, kind: fuzzValueTemplate},
fuzzValueLayer{values: evaluatedValues, kind: fuzzValueTemplate},
fuzzValueLayer{values: baseValues, kind: fuzzValueData},
fuzzValueLayer{values: optionVars, kind: fuzzValueData},
fuzzValueLayer{values: constants, kind: fuzzValueData},
)
// Generator values are template-authored payload definitions until this
// render. Runtime/base, CLI option, and constant values remain data.
// render. Rendered variables, runtime/base, CLI option, and constant
// values remain data. Payloads layer over variables so a payload that
// shares a variable's name is still rendered.
return rule.evaluateVarsWithInteractsh(inputValues, interactURLs, dataKeys)
}
@@ -326,7 +319,7 @@ func (rule *Rule) evaluateVars(input string) (string, error) {
func (rule *Rule) evaluateVarsWithInteractsh(data map[string]interface{}, interactshUrls []string, dataKeys map[string]struct{}) (map[string]interface{}, []string) {
// Check if Interactsh options are configured
if rule.options.Interactsh != nil {
data = maps.Clone(data)
rendered := maps.Clone(data)
interactshUrlsMap := make(map[string]struct{})
for _, url := range interactshUrls {
@@ -335,7 +328,8 @@ func (rule *Rule) evaluateVarsWithInteractsh(data map[string]interface{}, intera
interactshUrls = mapsutil.GetKeys(interactshUrlsMap)
// Iterate through template-text data to replace Interactsh URL markers.
// Render each template-text value against the unrendered input, so
// the result does not depend on map iteration order.
for k, v := range data {
if _, ok := dataKeys[k]; ok {
continue
@@ -349,7 +343,7 @@ func (rule *Rule) evaluateVarsWithInteractsh(data map[string]interface{}, intera
InteractURLs: interactshUrls,
})
if err == nil {
data[k] = got.Text
rendered[k] = got.Text
for _, url := range got.InteractURLs {
if _, ok := interactshUrlsMap[url]; ok {
continue
@@ -360,6 +354,8 @@ func (rule *Rule) evaluateVarsWithInteractsh(data map[string]interface{}, intera
}
}
}
data = rendered
}
return data, interactshUrls
+224
View File
@@ -0,0 +1,224 @@
package fuzz
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/disk"
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/component"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/generators"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/variables"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/retryablehttp-go"
"github.com/stretchr/testify/require"
)
const (
// requestBodyWithDSL is request data that looks like a DSL expression.
requestBodyWithDSL = `PLAIN-{{to_upper("abc")}}-END`
queryRuleTarget = "http://127.0.0.1/echo?p=SEED"
)
// fakeInteractshClient hands out numbered URLs without contacting a server.
// Fuzz rules only allocate URLs through NewURLWithData, so the embedded nil
// IClient is never called.
type fakeInteractshClient struct {
interactsh.IClient
issued int
}
func (c *fakeInteractshClient) NewURLWithData(string) (string, error) {
c.issued++
return fmt.Sprintf("oast%d.example", c.issued), nil
}
// newQueryRule compiles a single-mode rule that replaces the p query value
// with fuzzValue.
func newQueryRule(
t *testing.T,
fuzzValue string,
generator *generators.PayloadGenerator,
templateVars variables.Variable,
client interactsh.IClient,
) *Rule {
t.Helper()
rule := &Rule{
Part: component.RequestQueryComponent,
Type: "replace",
Mode: "single",
Fuzz: SliceOrMapSlice{Value: []string{fuzzValue}},
}
err := rule.Compile(generator, &protocols.ExecutorOptions{
Variables: templateVars,
Options: &types.Options{},
Interactsh: client,
})
require.NoError(t, err)
return rule
}
// executeQueryRule runs rule against queryRuleTarget and returns the generated
// requests.
func executeQueryRule(
t *testing.T,
rule *Rule,
method string,
body io.Reader,
values map[string]any,
) []GeneratedRequest {
t.Helper()
req, err := retryablehttp.NewRequest(method, queryRuleTarget, body)
require.NoError(t, err)
var got []GeneratedRequest
err = rule.Execute(&ExecuteRuleInput{
Input: contextargs.NewWithInput(context.Background(), queryRuleTarget),
BaseRequest: req,
Values: values,
Callback: func(gr GeneratedRequest) bool {
got = append(got, gr)
return true
},
})
require.NoError(t, err)
return got
}
// newRequestDataVariableRule returns a query rule whose template declares
// b: "{{body}}", so b captures request data.
func newRequestDataVariableRule(t *testing.T, fuzzValue string, payloads map[string]any) *Rule {
t.Helper()
templateVars := newFuzzTestVariables()
templateVars.Set("b", "{{body}}")
var generator *generators.PayloadGenerator
if payloads != nil {
var err error
generator, err = generators.New(
payloads, generators.BatteringRamAttack, "", disk.NewCatalog(""), "", types.DefaultOptions(),
)
require.NoError(t, err)
}
// The CLI creates an Interactsh client even with -no-interactsh.
return newQueryRule(t, fuzzValue, generator, templateVars, &interactsh.Client{})
}
// fuzzedQueryValues runs rule against a POST request whose body is
// requestBodyWithDSL and returns the fuzzed p query value of each generated
// request.
func fuzzedQueryValues(t *testing.T, rule *Rule) []string {
t.Helper()
generated := executeQueryRule(t, rule, http.MethodPost, strings.NewReader(requestBodyWithDSL), map[string]any{
"body": requestBodyWithDSL,
})
got := make([]string, 0, len(generated))
for _, gr := range generated {
got = append(got, gr.Request.URL.Query().Get("p"))
}
return got
}
func TestExecuteKeepsRequestDataInTemplateVariableLiteral(t *testing.T) {
t.Parallel()
t.Run("fuzz value", func(t *testing.T) {
t.Parallel()
rule := newRequestDataVariableRule(t, "{{b}}", nil)
require.Equal(t, []string{requestBodyWithDSL}, fuzzedQueryValues(t, rule))
})
t.Run("payload value", func(t *testing.T) {
t.Parallel()
rule := newRequestDataVariableRule(t, "{{inj}}", map[string]any{
"inj": []any{"{{b}}"},
})
// Each run iterates a fresh value map. Repeat so the result
// cannot depend on map iteration order.
for range 256 {
require.Equal(t, []string{requestBodyWithDSL}, fuzzedQueryValues(t, rule))
}
})
}
func TestExecuteRendersTemplateVariableInteractshMarker(t *testing.T) {
t.Parallel()
templateVars := newFuzzTestVariables()
templateVars.Set("marker", "{{interactsh-url}}")
rule := newQueryRule(t, "{{marker}}", nil, templateVars, &fakeInteractshClient{})
got := executeQueryRule(t, rule, http.MethodGet, nil, nil)
require.Len(t, got, 1)
require.Equal(t, []string{"oast1.example"}, got[0].InteractURLs)
require.Equal(t, "oast1.example", got[0].Request.URL.Query().Get("p"))
}
func TestPrepareGeneratorValuesRendersPayloadSharingVariableName(t *testing.T) {
t.Parallel()
templateVars := newFuzzTestVariables()
templateVars.Set("inj", "variable")
rule := &Rule{
options: &protocols.ExecutorOptions{
Variables: templateVars,
Options: &types.Options{},
Interactsh: &fakeInteractshClient{},
},
}
values, _ := rule.prepareGeneratorValues(map[string]any{
"inj": `{{to_upper("payload")}}`,
}, nil)
require.Equal(t, "PAYLOAD", values["inj"])
}
func TestPrepareGeneratorValuesRendersPayloadsAgainstUnrenderedInput(t *testing.T) {
t.Parallel()
rule := &Rule{
options: &protocols.ExecutorOptions{
Variables: newFuzzTestVariables(),
Options: &types.Options{},
Interactsh: &interactsh.Client{},
},
}
for range 256 {
values, _ := rule.prepareGeneratorValues(map[string]any{
"upper": `{{to_upper("x")}}`,
"ref": "{{upper}}",
}, nil)
require.Equal(t, "X", values["upper"])
// ref sees the unrendered definition of upper as data.
require.Equal(t, `{{to_upper("x")}}`, values["ref"])
}
}
+1
View File
@@ -5,6 +5,7 @@ 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
@@ -0,0 +1,137 @@
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
@@ -0,0 +1,71 @@
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
@@ -0,0 +1,20 @@
//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
@@ -0,0 +1,32 @@
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
}
@@ -0,0 +1,84 @@
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")
}
+14 -1
View File
@@ -9,6 +9,7 @@ import (
"net/http/httputil"
"net/url"
"os"
"slices"
"strings"
"github.com/clbanning/mxj/v2"
@@ -33,7 +34,9 @@ const (
// GenerateRequestsFromSchema generates http requests from an OpenAPI 3.0 document object
func GenerateRequestsFromSchema(schema *openapi3.T, opts formats.InputFormatOptions, callback formats.ParseReqRespCallback) error {
if len(schema.Servers) == 0 {
// 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 }) {
return errors.New("no servers found in openapi schema")
}
globalParams, globalSecurityErr := selectSecurityParameters(schema, &schema.Security, opts.Variables)
@@ -49,6 +52,11 @@ 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)
@@ -268,6 +276,11 @@ 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{}
@@ -0,0 +1 @@
{"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"}}}}}}
@@ -0,0 +1,7 @@
openapi: 3.0.0
info: {title: t, version: "1"}
servers: [{url: "http://example.com"}]
paths:
/users:
get:
responses: {"200": {description: ok}}
@@ -0,0 +1,12 @@
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}}
@@ -0,0 +1,9 @@
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,7 +3,9 @@ package swagger
import (
"fmt"
"io"
"maps"
"path"
"slices"
"github.com/getkin/kin-openapi/openapi2"
"github.com/getkin/kin-openapi/openapi2conv"
@@ -55,6 +57,7 @@ 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")
@@ -67,6 +70,32 @@ 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,6 +2,7 @@ package swagger
import (
"os"
"strings"
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/input/types"
@@ -39,3 +40,69 @@ 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
@@ -0,0 +1,71 @@
{
"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,6 +10,7 @@ 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"
@@ -142,6 +143,7 @@ func (i *HttpInputProvider) Close() {}
// Supported Providers
var providersList = []formats.Format{
burp.New(),
har.New(),
json.New(),
yaml.New(),
openapi.New(),
+66 -138
View File
@@ -89,8 +89,9 @@ func New(opts *Options) (*ListInputProvider, error) {
patterns: normalize.NewFingerprinter(options.MaxURLsPerPattern),
ipOptions: &ipOptions{
ScanAllIPs: options.ScanAllIPs,
IPV4: sliceutil.Contains(options.IPVersion, "4"),
IPV6: sliceutil.Contains(options.IPVersion, "6"),
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,
},
excludedHosts: make(map[string]struct{}),
profiles: targetprofile.NewRegistry(configuration.TemplatesDirectory(options, config.DefaultConfig)),
@@ -168,6 +169,61 @@ 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)
@@ -202,75 +258,11 @@ func (i *ListInputProvider) set(executionId string, value string, selection *tar
return
}
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 {
for _, ip := range i.selectedIPs(executionId, urlx.Hostname()) {
metaInput := contextargs.NewMetaInput()
if ip != "" {
metaInput.Input = URL
metaInput.CustomIP = ip
i.setItem(metaInput, selection)
} else {
metaInput.Input = URL
i.setItem(metaInput, selection)
}
metaInput.Input = URL
metaInput.CustomIP = ip
i.setItem(metaInput, selection)
}
}
@@ -493,75 +485,11 @@ func (i *ListInputProvider) Del(executionId string, value string) {
return
}
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 {
for _, ip := range i.selectedIPs(executionId, urlx.Hostname()) {
metaInput := contextargs.NewMetaInput()
if ip != "" {
metaInput.Input = URL
metaInput.CustomIP = ip
i.delItem(metaInput)
} else {
metaInput.Input = URL
i.delItem(metaInput)
}
metaInput.Input = URL
metaInput.CustomIP = ip
i.delItem(metaInput)
}
}
+106
View File
@@ -0,0 +1,106 @@
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,4 +4,5 @@ type ipOptions struct {
ScanAllIPs bool
IPV4 bool
IPV6 bool
Auto bool // -iv omitted: one unpinned target unless -sa scans every address
}
-14
View File
@@ -16,14 +16,6 @@ import (
// Helper is a structure for helping with input transformation
type Helper struct {
InputsHTTP *hybrid.HybridMap
// InputsHTTPProbed is true when InputsHTTP was filled by httpx probing.
// False means probing was skipped (e.g. MultiFormat) and map absence must
// not be treated as "not HTTP".
InputsHTTPProbed bool
// StrictProbe, when set, makes web-template input resolution skip inputs that
// were probed and found NOT to be HTTP services (instead of falling back to
// a raw URL). Lossless: a non-HTTP port has no HTTP application to find.
StrictProbe bool
}
// NewHelper returns a new input helper instance
@@ -148,12 +140,6 @@ func (h *Helper) convertInputToType(input string, inputType inputType, defaultPo
if probed, ok := h.InputsHTTP.Get(input); ok {
return string(probed)
}
// Strict reachability: this input was probed and is not an HTTP
// service, so skip it for web templates rather than falling back to
// a raw URL. Lossless — a non-HTTP port has no HTTP app to match.
if h.StrictProbe {
return ""
}
}
// try to parse it as absolute url and return
if absUrl, err := urlutil.ParseAbsoluteURL(input, false); err == nil {
-29
View File
@@ -91,32 +91,3 @@ func TestConvertInputToType(t *testing.T) {
require.Equal(t, test.result, result, "could not get correct result %+v", test)
}
}
func TestStrictProbeSkipsUnconfirmedHTTPInput(t *testing.T) {
hm, err := hybrid.New(hybrid.DefaultDiskOptions)
require.NoError(t, err)
t.Cleanup(func() { _ = hm.Close() })
_ = hm.Set("ok.example", []byte("https://ok.example"))
helper := &Helper{InputsHTTP: hm, StrictProbe: true}
// confirmed host -> resolved URL
require.Equal(t, "https://ok.example", helper.convertInputToType("ok.example", typeURL, ""))
// probed but non-HTTP hosts -> skipped (no raw fallback)
require.Equal(t, "", helper.convertInputToType("127.0.0.1:3306", typeURL, ""))
require.Equal(t, "", helper.convertInputToType("zombie.example:6379", typeURL, ""))
// already-URL inputs are not gated by probe results
require.Equal(t, "https://direct.example", helper.convertInputToType("https://direct.example", typeURL, ""))
// non-HTTP protocols still transform normally
require.Equal(t, "127.0.0.1:3306", helper.convertInputToType("127.0.0.1:3306", typeHostWithOptionalPort, ""))
}
func TestStrictProbeDisabledKeepsRawFallback(t *testing.T) {
hm, err := hybrid.New(hybrid.DefaultDiskOptions)
require.NoError(t, err)
t.Cleanup(func() { _ = hm.Close() })
helper := &Helper{InputsHTTP: hm, StrictProbe: false}
require.Equal(t, "127.0.0.1:3306", helper.convertInputToType("127.0.0.1:3306", typeURL, ""))
}
+53
View File
@@ -0,0 +1,53 @@
package compiler_test
//nolint:depguard // No depguard rules are configured, so its default list allows only the standard library.
import (
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/js/compiler"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
)
// TestCloseWithoutConnection calls Close on clients that never connected.
// The script must keep running past the call.
func TestCloseWithoutConnection(t *testing.T) {
t.Parallel()
scripts := map[string]string{
"ssh": `
const ssh = require('nuclei/ssh');
ExportAs('closed', new ssh.SSHClient().Close());
true;
`,
"net": `
const net = require('nuclei/net');
new net.NetConn().Close();
ExportAs('closed', true);
true;
`,
}
for name, script := range scripts {
t.Run(name, func(t *testing.T) {
t.Parallel()
program, err := compiler.SourceAutoMode(script, false)
if err != nil {
t.Fatal(err)
}
opts := new(compiler.ExecuteOptions)
opts.ExecutionId = t.Name()
opts.Source = &script // ExportAs is defined only for sources that use it.
opts.TimeoutVariants = types.NewTimeoutVariant(5)
result, err := compiler.New().ExecuteWithOptions(t.Context(), program, compiler.NewExecuteArgs(), opts)
if err != nil {
t.Fatal(err)
}
if got := result["closed"]; got != "true" {
t.Errorf(`result["closed"] = %v, want "true"`, got)
}
})
}
}
+4
View File
@@ -16,18 +16,22 @@ 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/libipmi"
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libkerberos"
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libkrbforge"
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libkrbroast"
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libldap"
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libmssql"
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libmysql"
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libmqtt"
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libnet"
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/liboracle"
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libpop3"
+158
View File
@@ -0,0 +1,158 @@
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"])
})
}
}
@@ -0,0 +1,30 @@
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
@@ -0,0 +1,30 @@
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)
}
+39
View File
@@ -0,0 +1,39 @@
package ipmi
import (
lib_ipmi "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/ipmi"
"github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
var (
module = gojs.NewGojaModule("nuclei/ipmi")
)
func init() {
module.Set(
gojs.Objects{
// Functions
"CheckRAKPPassword": lib_ipmi.CheckRAKPPassword,
"FindRAKPPassword": lib_ipmi.FindRAKPPassword,
"GetAuthCapabilities": lib_ipmi.GetAuthCapabilities,
"GetCipherSuites": lib_ipmi.GetCipherSuites,
"GetRAKPHash": lib_ipmi.GetRAKPHash,
"GetSystemGUID": lib_ipmi.GetSystemGUID,
"Ping": lib_ipmi.Ping,
// Var and consts
// Objects / Classes
"AuthCapabilities": gojs.GetClassConstructor[lib_ipmi.AuthCapabilities](&lib_ipmi.AuthCapabilities{}),
"Client": gojs.GetClassConstructor[lib_ipmi.Client](&lib_ipmi.Client{}),
"CommandResponse": gojs.GetClassConstructor[lib_ipmi.CommandResponse](&lib_ipmi.CommandResponse{}),
"RAKPHash": gojs.GetClassConstructor[lib_ipmi.RAKPHash](&lib_ipmi.RAKPHash{}),
},
).Register()
}
func Enable(runtime *goja.Runtime) {
module.Enable(runtime)
}
+34
View File
@@ -0,0 +1,34 @@
package mqtt
import (
lib_mqtt "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/mqtt"
"github.com/projectdiscovery/goja"
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
)
var (
module = gojs.NewGojaModule("nuclei/mqtt")
)
func init() {
module.Set(
gojs.Objects{
// Functions
"IsMQTT": lib_mqtt.IsMQTT,
"IsAnonymousAllowed": lib_mqtt.IsAnonymousAllowed,
// Var and consts
// Objects / Classes
"Client": gojs.GetClassConstructor[lib_mqtt.Client](&lib_mqtt.Client{}),
"ConnectOptions": gojs.GetClassConstructor[lib_mqtt.ConnectOptions](&lib_mqtt.ConnectOptions{}),
"ConnectResult": gojs.GetClassConstructor[lib_mqtt.ConnectResult](&lib_mqtt.ConnectResult{}),
"Message": gojs.GetClassConstructor[lib_mqtt.Message](&lib_mqtt.Message{}),
},
).Register()
}
func Enable(runtime *goja.Runtime) {
module.Enable(runtime)
}
+102
View File
@@ -0,0 +1,102 @@
/**
* 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
@@ -0,0 +1,77 @@
/**
* 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;
}
}
+4
View File
@@ -1,17 +1,21 @@
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 ipmi from './ipmi';
export * as kerberos from './kerberos';
export * as krbforge from './krbforge';
export * as krbroast from './krbroast';
export * as ldap from './ldap';
export * as mssql from './mssql';
export * as mysql from './mysql';
export * as mqtt from './mqtt';
export * as net from './net';
export * as oracle from './oracle';
export * as pop3 from './pop3';
+304
View File
@@ -0,0 +1,304 @@
/**
* CheckRAKPPassword reports whether password produced hash. It runs offline,
* so it can test any number of passwords without touching the BMC.
* @example
* ```javascript
* const ipmi = require('nuclei/ipmi');
* const hash = ipmi.GetRAKPHash('acme.com', 623, 'ADMIN');
* log(ipmi.CheckRAKPPassword(hash, 'ADMIN'));
* ```
*/
export function CheckRAKPPassword(hash: RAKPHash, password: string): boolean {
return false;
}
/**
* FindRAKPPassword returns the first of passwords that produced hash, or an
* empty string.
* @example
* ```javascript
* const ipmi = require('nuclei/ipmi');
* const hash = ipmi.GetRAKPHash('acme.com', 623, 'root');
* log(ipmi.FindRAKPPassword(hash, ['calvin', 'root', 'admin']));
* ```
*/
export function FindRAKPPassword(hash: RAKPHash, passwords: string[]): string {
return "";
}
/**
* GetAuthCapabilities returns the authentication capabilities the BMC
* advertises on the current channel.
* @example
* ```javascript
* const ipmi = require('nuclei/ipmi');
* const caps = ipmi.GetAuthCapabilities('acme.com', 623);
* log(caps.AnonymousLogin);
* ```
*/
export function GetAuthCapabilities(ctx: any, host: string, port: number): AuthCapabilities | null {
return null;
}
/**
* GetCipherSuites returns the IDs of the RMCP+ cipher suites the BMC
* offers. Cipher suite 0 authenticates nothing, so any password logs in.
* @example
* ```javascript
* const ipmi = require('nuclei/ipmi');
* const suites = ipmi.GetCipherSuites('acme.com', 623);
* log(suites.includes(0));
* ```
*/
export function GetCipherSuites(ctx: any, host: string, port: number): number[] | null {
return null;
}
/**
* GetRAKPHash starts an RMCP+ session as username and returns the HMAC the
* BMC answers with. The session is never completed, so no login is
* attempted; the BMC drops the half-open session after its timeout. It fails
* when the BMC rejects the username.
* @example
* ```javascript
* const ipmi = require('nuclei/ipmi');
* const hash = ipmi.GetRAKPHash('acme.com', 623, 'ADMIN');
* log(ipmi.CheckRAKPPassword(hash, 'ADMIN'));
* ```
*/
export function GetRAKPHash(ctx: any, host: string, port: number, username: string): RAKPHash | null {
return null;
}
/**
* GetSystemGUID returns the BMC system GUID, which vendors derive from
* serial numbers or MAC addresses.
* @example
* ```javascript
* const ipmi = require('nuclei/ipmi');
* const guid = ipmi.GetSystemGUID('acme.com', 623);
* log(guid);
* ```
*/
export function GetSystemGUID(ctx: any, host: string, port: number): string | null {
return null;
}
/**
* Ping reports whether the host answers an RMCP presence ping and advertises
* IPMI support.
* @example
* ```javascript
* const ipmi = require('nuclei/ipmi');
* const isIPMI = ipmi.Ping('acme.com', 623);
* log(isIPMI);
* ```
*/
export function Ping(ctx: any, host: string, port: number): boolean | null {
return null;
}
/**
* Client holds one RMCP+ session, for commands that need a login.
* Close it when done: BMCs allow only a few sessions at a time.
* @example
* ```javascript
* const ipmi = require('nuclei/ipmi');
* const client = new ipmi.Client();
* client.Login('acme.com', 623, 'ADMIN', 'ADMIN', 3);
* const id = client.Command(0x06, 0x01, []);
* client.Close();
* ```
*/
export class Client {
// Constructor of Client
constructor() {}
/**
* Login opens an RMCP+ session as username with the given cipher suite
* (0, 1, 2, 3 or 6). Cipher suite 0 skips authentication, so a BMC
* that allows it accepts any password. A rejected login is an error.
* @example
* ```javascript
* const ipmi = require('nuclei/ipmi');
* const client = new ipmi.Client();
* client.Login('acme.com', 623, 'ADMIN', 'not-the-password', 0);
* ```
*/
public Login(ctx: any, host: string, port: number, username: string, password: string, cipherSuite: number): boolean | null {
return null;
}
/**
* Command sends an IPMI request in the session, such as Get Device ID
* (netFn 0x06, cmd 0x01) or Set Session Privilege Level (0x06, 0x3b).
* @example
* ```javascript
* const ipmi = require('nuclei/ipmi');
* const client = new ipmi.Client();
* client.Login('acme.com', 623, 'ADMIN', 'ADMIN', 3);
* const admin = client.Command(0x06, 0x3b, [0x04]).CompletionCode == 0;
* client.Close();
* ```
*/
public Command(netFn: number, cmd: number, data: Uint8Array): CommandResponse | null {
return null;
}
/**
* Close ends the session on the BMC, freeing its slot.
* @example
* ```javascript
* const ipmi = require('nuclei/ipmi');
* const client = new ipmi.Client();
* client.Login('acme.com', 623, 'ADMIN', 'ADMIN', 3);
* client.Close();
* ```
*/
public Close(): void {
return;
}
}
/**
* AuthCapabilities is what a BMC advertises before any login, from Get
* Channel Authentication Capabilities.
* @example
* ```javascript
* const ipmi = require('nuclei/ipmi');
* const caps = ipmi.GetAuthCapabilities('acme.com', 623);
* log(toJSON(caps));
* ```
*/
export interface AuthCapabilities {
/**
* IPMI20 is true when the BMC supports IPMI 2.0 (RMCP+).
*/
IPMI20?: boolean,
/**
* AuthTypes are the IPMI 1.5 authentication types the channel
* accepts: none, md2, md5, straight (cleartext password), oem.
*/
AuthTypes?: string[],
/**
* AnonymousLogin is true when a null username with a null password
* is accepted.
*/
AnonymousLogin?: boolean,
/**
* NullUsernames is true when accounts with an empty name are enabled.
*/
NullUsernames?: boolean,
/**
* NonNullUsernames is true when named accounts are enabled.
*/
NonNullUsernames?: boolean,
/**
* PerMessageAuth is false when the BMC skips authenticating
* messages after login.
*/
PerMessageAuth?: boolean,
/**
* UserLevelAuth is false when user-level commands skip
* authentication.
*/
UserLevelAuth?: boolean,
/**
* OEMID is the IANA enterprise number of the BMC vendor, or 0.
*/
OEMID?: number,
}
/**
* CommandResponse is the reply to an IPMI command.
*/
export interface CommandResponse {
/**
* CompletionCode is 0 on success.
*/
CompletionCode?: number,
Data?: Uint8Array,
}
/**
* RAKPHash is the HMAC a BMC returns for a user during RMCP+ session
* setup (CVE-2013-4786). It can be checked offline against candidate
* passwords, which tests credentials without a login attempt.
* @example
* ```javascript
* const ipmi = require('nuclei/ipmi');
* const hash = ipmi.GetRAKPHash('acme.com', 623, 'ADMIN');
* log(hash.Hashcat);
* ```
*/
export interface RAKPHash {
Username?: string,
/**
* Algorithm is the HMAC the BMC used: sha1 or md5.
*/
Algorithm?: string,
/**
* Salt and HMAC are hex encoded; Salt is everything the HMAC covers.
*/
Salt?: string,
HMAC?: string,
/**
* Hashcat is the hash in hashcat mode 7300 format, for sha1 only.
*/
Hashcat?: string,
}
+190
View File
@@ -0,0 +1,190 @@
/**
* IsMQTT checks whether the target host:port is running an MQTT broker.
* @example
* ```javascript
* const mqtt = require('nuclei/mqtt');
* const isMqtt = mqtt.IsMQTT('acme.com', 1883);
* log(isMqtt);
* ```
*/
export function IsMQTT(host: string, port: number): boolean | null {
return null;
}
/**
* IsAnonymousAllowed checks whether an MQTT broker permits unauthenticated / anonymous login.
* @example
* ```javascript
* const mqtt = require('nuclei/mqtt');
* const allowed = mqtt.IsAnonymousAllowed('acme.com', 1883);
* log(allowed);
* ```
*/
export function IsAnonymousAllowed(host: string, port: number): boolean | null {
return null;
}
/**
* Message represents an MQTT published message.
* @example
* ```javascript
* const mqtt = require('nuclei/mqtt');
* const client = new mqtt.Client();
* client.Connect('broker.hivemq.com', 1883);
* const msgs = client.Subscribe('test/topic', 2);
* log(msgs[0].Topic, msgs[0].Payload);
* ```
*/
export interface Message {
Topic?: string;
Payload?: string;
QoS?: number;
Retain?: boolean;
}
/**
* ConnectOptions contains connection options for an MQTT broker.
* @example
* ```javascript
* const mqtt = require('nuclei/mqtt');
* const opts = new mqtt.ConnectOptions();
* opts.Username = 'admin';
* opts.Password = 'admin';
* opts.CleanSession = true;
* ```
*/
export interface ConnectOptions {
Username?: string;
Password?: string;
ClientID?: string;
CleanSession?: boolean;
KeepAlive?: number;
TLS?: boolean;
InsecureSkipVerify?: boolean;
Timeout?: number;
}
/**
* ConnectResult is the response returned by an MQTT connection attempt.
* @example
* ```javascript
* const mqtt = require('nuclei/mqtt');
* const client = new mqtt.Client();
* const res = client.Connect('acme.com', 1883);
* log(res.Success, res.ReturnCode, res.Reason);
* ```
*/
export interface ConnectResult {
Success?: boolean;
ReturnCode?: number;
Reason?: string;
SessionPresent?: boolean;
}
/**
* Client is an MQTT client for testing MQTT broker misconfigurations and authentication.
* @example
* ```javascript
* const mqtt = require('nuclei/mqtt');
* const client = new mqtt.Client();
* const res = client.Connect('acme.com', 1883);
* if (res.Success) {
* const msgs = client.Subscribe('#', 2);
* client.Publish('nuclei/probe', 'test');
* }
* client.Close();
* ```
*/
export class Client {
constructor() {}
/**
* Connect connects to an MQTT broker using optional credentials and settings.
* @example
* ```javascript
* const mqtt = require('nuclei/mqtt');
* const client = new mqtt.Client();
* const res = client.Connect('acme.com', 1883);
* log(res.Success);
* ```
*/
public Connect(host: string, port: number, options?: ConnectOptions): ConnectResult | null {
return null;
}
/**
* ConnectAuth connects to an MQTT broker with a username and password.
* @example
* ```javascript
* const mqtt = require('nuclei/mqtt');
* const client = new mqtt.Client();
* const res = client.ConnectAuth('acme.com', 1883, 'admin', 'admin');
* log(res.Success);
* ```
*/
public ConnectAuth(host: string, port: number, username: string, password: string): ConnectResult | null {
return null;
}
/**
* Subscribe subscribes to a topic filter and collects received messages up to timeoutSec.
* @example
* ```javascript
* const mqtt = require('nuclei/mqtt');
* const client = new mqtt.Client();
* client.Connect('acme.com', 1883);
* const msgs = client.Subscribe('#', 3);
* for (const m of msgs) {
* log(m.Topic, m.Payload);
* }
* ```
*/
public Subscribe(topic: string, timeoutSec?: number): Message[] | null {
return null;
}
/**
* Publish publishes a message payload to a topic.
* @example
* ```javascript
* const mqtt = require('nuclei/mqtt');
* const client = new mqtt.Client();
* client.Connect('acme.com', 1883);
* const ok = client.Publish('nuclei/test', 'probe payload');
* log(ok);
* ```
*/
public Publish(topic: string, payload: string): boolean | null {
return null;
}
/**
* Ping sends an MQTT PINGREQ and awaits PINGRESP.
* @example
* ```javascript
* const mqtt = require('nuclei/mqtt');
* const client = new mqtt.Client();
* client.Connect('acme.com', 1883);
* const alive = client.Ping();
* log(alive);
* ```
*/
public Ping(): boolean | null {
return null;
}
/**
* Close cleanly disconnects and closes the connection.
* @example
* ```javascript
* const mqtt = require('nuclei/mqtt');
* const client = new mqtt.Client();
* client.Connect('acme.com', 1883);
* client.Close();
* ```
*/
public Close(): error | null {
return null;
}
}
+1
View File
@@ -46,6 +46,7 @@ export class NetConn {
constructor() {}
/**
* Close closes the connection.
* Close does nothing if no connection is open.
* @example
* ```javascript
* const net = require('nuclei/net');
+1 -1
View File
@@ -2,7 +2,7 @@
/**
* PGClient is a client for Postgres database.
* Internally client uses go-pg/pg driver.
* Internally client uses lib/pq driver.
* @example
* ```javascript
* const postgres = require('nuclei/postgres');
+2 -1
View File
@@ -106,7 +106,8 @@ export class SSHClient {
/**
* Close closes the SSH connection and destroys the client
* Returns the success state and error. If error is not nil,
* state will be false
* state will be false. Close does nothing and returns true
* if no connection is open.
* @example
* ```javascript
* const ssh = require('nuclei/ssh');
+6 -2
View File
@@ -128,9 +128,13 @@ func (p *GojaModule) Register() Module {
return p
}
// GetClassConstructor returns a constructor for any given go struct type for goja runtime
// GetClassConstructor returns a constructor for any given go struct type for goja runtime.
// Each object it constructs starts as a shallow copy of instance, so objects
// do not share state.
func GetClassConstructor[T any](instance *T) func(call goja.ConstructorCall, runtime *goja.Runtime) *goja.Object {
return func(call goja.ConstructorCall, runtime *goja.Runtime) *goja.Object {
return utils.LinkConstructor[*T](call, runtime, instance)
obj := *instance
return utils.LinkConstructor(call, runtime, &obj)
}
}
Loaded 100 of 230 files, more files were not shown because too many files have changed in this diff. Show more