Compare commits

...
Author SHA1 Message Date
Mzack9999 f2f89d0b4a Merge remote-tracking branch 'origin/dev' into feat-performance-improvements
# Conflicts:
#	pkg/operators/cache/cache.go
#	pkg/operators/matchers/match.go
#	pkg/output/multi_writer.go
#	pkg/protocols/common/expressions/expressions.go
#	pkg/protocols/common/expressions/variables.go
2026-06-10 22:58:14 +02:00
Mzack9999 444a21dfe2 review fixes 2026-06-10 22:49:47 +02:00
Mzack9999 a0e4599b2a . 2026-05-11 14:40:35 +02:00
Mzack9999 44fc97217d adding parallel workflow loading 2026-05-11 00:31:34 +02:00
Mzack9999 bebec6027c adding concurrent MultiWriter fan-out 2026-05-11 00:31:27 +02:00
Mzack9999 7f42a669b2 adding buffered tasks channel and larger SPM result buffer 2026-05-11 00:31:21 +02:00
Mzack9999 dac099daee adding skip of duplicate request dump when no debug consumer 2026-05-11 00:31:13 +02:00
Mzack9999 06c977f90c adding cached interactsh HasMatchers on Request 2026-05-11 00:31:03 +02:00
Mzack9999 7e450495a8 adding jq program compile cache 2026-05-11 00:30:34 +02:00
Mzack9999 c48c506692 adding typed fast paths to MergeMapsMany 2026-05-11 00:30:28 +02:00
Mzack9999 0af846340b adding lazy fasttemplate replacer 2026-05-11 00:30:21 +02:00
Mzack9999 1f6a429ca9 adding per-response body decode cache 2026-05-11 00:30:15 +02:00
Mzack9999 589e776426 adding compiled expression cache 2026-05-11 00:30:07 +02:00
20 changed files with 1278 additions and 145 deletions

No files matched your search

+63 -16
View File
@@ -727,28 +727,75 @@ func (store *Store) LoadTemplates(templatesList []string) ([]*templates.Template
return store.LoadTemplatesWithTags(templatesList, nil)
}
// LoadWorkflows takes a list of workflows and returns paths for them
// LoadWorkflows takes a list of workflows and returns paths for them.
//
// Workflows are loaded concurrently using TemplateLoadingConcurrency (mirrors
// the concurrency model of LoadTemplatesWithTags). Order is preserved by
// pre-allocating the result slice and writing into the original index, so
// downstream consumers see the same order as the input list.
func (store *Store) LoadWorkflows(workflowsList []string) []*templates.Template {
includedWorkflows, errs := store.config.Catalog.GetTemplatesPath(workflowsList)
store.logErroredTemplates(errs)
loadedWorkflows := make([]*templates.Template, 0, len(includedWorkflows))
for _, workflowPath := range includedWorkflows {
loaded, err := store.config.ExecutorOptions.Parser.LoadWorkflow(workflowPath, store.config.Catalog)
if err != nil {
store.logger.Warning().Msgf("Could not load workflow %s: %s\n", workflowPath, err)
}
if loaded {
parsed, err := templates.Parse(workflowPath, store.preprocessor, store.config.ExecutorOptions)
if err != nil {
store.logger.Warning().Msgf("Could not parse workflow %s: %s\n", workflowPath, err)
} else if parsed != nil {
loadedWorkflows = append(loadedWorkflows, parsed)
}
}
if len(includedWorkflows) == 0 {
return nil
}
concurrency := store.config.ExecutorOptions.Options.TemplateLoadingConcurrency
if concurrency <= 0 {
concurrency = types.DefaultTemplateLoadingConcurrency
}
wg, errWg := syncutil.New(syncutil.WithSize(concurrency))
if errWg != nil {
// Fall back to sequential on the rare error path.
loadedWorkflows := make([]*templates.Template, 0, len(includedWorkflows))
for _, workflowPath := range includedWorkflows {
loaded, err := store.config.ExecutorOptions.Parser.LoadWorkflow(workflowPath, store.config.Catalog)
if err != nil {
store.logger.Warning().Msgf("Could not load workflow %s: %s\n", workflowPath, err)
}
if loaded {
parsed, err := templates.Parse(workflowPath, store.preprocessor, store.config.ExecutorOptions)
if err != nil {
store.logger.Warning().Msgf("Could not parse workflow %s: %s\n", workflowPath, err)
} else if parsed != nil {
loadedWorkflows = append(loadedWorkflows, parsed)
}
}
}
return loadedWorkflows
}
parsedSlots := make([]*templates.Template, len(includedWorkflows))
for i, workflowPath := range includedWorkflows {
wg.Add()
go func(idx int, path string) {
defer wg.Done()
loaded, err := store.config.ExecutorOptions.Parser.LoadWorkflow(path, store.config.Catalog)
if err != nil {
store.logger.Warning().Msgf("Could not load workflow %s: %s\n", path, err)
}
if !loaded {
return
}
parsed, err := templates.Parse(path, store.preprocessor, store.config.ExecutorOptions)
if err != nil {
store.logger.Warning().Msgf("Could not parse workflow %s: %s\n", path, err)
return
}
if parsed != nil {
parsedSlots[idx] = parsed
}
}(i, workflowPath)
}
wg.Wait()
loadedWorkflows := make([]*templates.Template, 0, len(includedWorkflows))
for _, p := range parsedSlots {
if p != nil {
loadedWorkflows = append(loadedWorkflows, p)
}
}
return loadedWorkflows
}
+8 -1
View File
@@ -90,7 +90,14 @@ func (e *Engine) executeTemplateWithTargets(ctx context.Context, template *templ
value *contextargs.MetaInput
}
tasks := make(chan task)
// Buffered to workerCount so the producer (target.Iterate) does not block
// on every send when workers are momentarily busy. This keeps the input
// iterator advancing and reduces tail latency on slow targets.
taskBuf := workerCount
if taskBuf < 1 {
taskBuf = 1
}
tasks := make(chan task, taskBuf)
var workersWg sync.WaitGroup
workersWg.Add(workerCount)
for i := 0; i < workerCount; i++ {
+14
View File
@@ -4,6 +4,7 @@ import (
"regexp"
"sync"
"github.com/itchyny/gojq"
"github.com/projectdiscovery/gcache"
"github.com/projectdiscovery/govaluate"
)
@@ -14,15 +15,18 @@ var (
regexCap = 4096
dslCap = 4096
jqCap = 2048
regexCache gcache.Cache[string, *regexp.Regexp]
dslCache gcache.Cache[string, *govaluate.EvaluableExpression]
jqCache gcache.Cache[string, *gojq.Code]
)
func initCaches() {
initOnce.Do(func() {
regexCache = gcache.New[string, *regexp.Regexp](regexCap).LRU().Build()
dslCache = gcache.New[string, *govaluate.EvaluableExpression](dslCap).LRU().Build()
jqCache = gcache.New[string, *gojq.Code](jqCap).LRU().Build()
})
}
@@ -60,3 +64,13 @@ func DSL() gcache.Cache[string, *govaluate.EvaluableExpression] {
defer mu.RUnlock()
return dslCache
}
// JQ returns the shared LRU cache for compiled gojq programs. Sharing one
// cache across all extractors means that thousands of templates referencing
// the same JQ query (e.g. `.cves[]`) compile it once.
func JQ() gcache.Cache[string, *gojq.Code] {
initCaches()
mu.RLock()
defer mu.RUnlock()
return jqCache
}
+139
View File
@@ -0,0 +1,139 @@
// Package bodycache memoizes per-response HTML, XML and JSON parses so that
// multiple matchers and extractors against the same response share a single
// parse. Without this, each MatchHTML/MatchXML/ExtractHTML/ExtractXML/ExtractJSON
// call re-parses the body independently, which is a substantial slice of the
// per-response CPU budget for clustered or matcher-heavy templates.
//
// The cache is intentionally tiny: a single most-recent entry per parser
// type. The vast majority of matchers in one operator block target the same
// corpus (typically the response body), so a one-slot LRU achieves near-100%
// hit rate without map allocation or hashing of large body strings.
package bodycache
import (
"strings"
"sync"
"github.com/antchfx/htmlquery"
"github.com/antchfx/xmlquery"
"golang.org/x/net/html"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
)
// Key is the reserved entry on output.InternalEvent / data maps that holds
// a *Cache. Underscored prefix avoids collisions with template variables and
// DSL identifiers.
const Key = "__nuclei_bodycache"
// Cache stores at most one parsed form per parser type. Matchers/extractors
// typically run sequentially against one response on one goroutine, but the
// same cache can outlive that goroutine when it is stored on an event map
// that is later re-evaluated (e.g. interactsh OOB matching). The mutex keeps
// the memoized parses safe if the cache is ever touched concurrently; it is
// uncontended on the common single-goroutine path.
type Cache struct {
mu sync.Mutex
htmlCorpus string
htmlDoc *html.Node
htmlErr error
htmlSet bool
xmlCorpus string
xmlDoc *xmlquery.Node
xmlErr error
xmlSet bool
jsonCorpus string
jsonObj interface{}
jsonErr error
jsonSet bool
loweredCorpus string
loweredOut string
loweredSet bool
}
// From returns the Cache associated with data, creating one on demand. Calls
// from within Match/Extract dispatch share the same instance for the lifetime
// of a single response event.
func From(data map[string]interface{}) *Cache {
if data == nil {
return &Cache{}
}
if v, ok := data[Key]; ok {
if c, ok := v.(*Cache); ok && c != nil {
return c
}
}
c := &Cache{}
data[Key] = c
return c
}
// HTMLNode returns the parsed HTML tree for corpus. Subsequent calls with
// the same corpus return the cached parse; calls with a different corpus
// re-parse and replace the cache entry.
func (c *Cache) HTMLNode(corpus string) (*html.Node, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.htmlSet && c.htmlCorpus == corpus {
return c.htmlDoc, c.htmlErr
}
doc, err := htmlquery.Parse(strings.NewReader(corpus))
c.htmlCorpus = corpus
c.htmlDoc = doc
c.htmlErr = err
c.htmlSet = true
return doc, err
}
// XMLNode returns the parsed XML tree for corpus. Same caching contract as
// HTMLNode.
func (c *Cache) XMLNode(corpus string) (*xmlquery.Node, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.xmlSet && c.xmlCorpus == corpus {
return c.xmlDoc, c.xmlErr
}
doc, err := xmlquery.Parse(strings.NewReader(corpus))
c.xmlCorpus = corpus
c.xmlDoc = doc
c.xmlErr = err
c.xmlSet = true
return doc, err
}
// JSONObject returns the unmarshaled JSON value for corpus. Same caching
// contract as HTMLNode.
func (c *Cache) JSONObject(corpus string) (interface{}, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.jsonSet && c.jsonCorpus == corpus {
return c.jsonObj, c.jsonErr
}
var obj interface{}
err := json.Unmarshal([]byte(corpus), &obj)
c.jsonCorpus = corpus
c.jsonObj = obj
c.jsonErr = err
c.jsonSet = true
return obj, err
}
// Lowered returns the lower-cased form of corpus. Cached so repeated
// case-insensitive word matchers against the same body avoid redundant
// O(n) ToLower allocations.
func (c *Cache) Lowered(corpus string) string {
c.mu.Lock()
defer c.mu.Unlock()
if c.loweredSet && c.loweredCorpus == corpus {
return c.loweredOut
}
out := strings.ToLower(corpus)
c.loweredCorpus = corpus
c.loweredOut = out
c.loweredSet = true
return out
}
@@ -0,0 +1,84 @@
package bodycache
import (
"sync"
"testing"
"github.com/stretchr/testify/require"
)
func TestFrom_CreatesAndReuses(t *testing.T) {
data := map[string]interface{}{}
c1 := From(data)
require.NotNil(t, c1)
c2 := From(data)
require.Same(t, c1, c2, "From must return the cached instance stored on the map")
// nil map is tolerated and yields a throwaway cache.
require.NotNil(t, From(nil))
}
func TestFrom_IgnoresWrongType(t *testing.T) {
data := map[string]interface{}{Key: "not-a-cache"}
c := From(data)
require.NotNil(t, c)
if _, ok := data[Key].(*Cache); !ok {
t.Fatalf("From must overwrite a non-*Cache value at Key with a real cache")
}
}
func TestCache_HTMLMemoizesSameCorpus(t *testing.T) {
c := &Cache{}
const corpus = `<html><body><a href="x">link</a></body></html>`
doc1, err1 := c.HTMLNode(corpus)
doc2, err2 := c.HTMLNode(corpus)
require.NoError(t, err1)
require.NoError(t, err2)
require.Same(t, doc1, doc2, "same corpus must return the memoized parse")
}
func TestCache_HTMLReparsesOnCorpusChange(t *testing.T) {
c := &Cache{}
doc1, _ := c.HTMLNode(`<html><body>a</body></html>`)
doc2, _ := c.HTMLNode(`<html><body>b</body></html>`)
require.NotSame(t, doc1, doc2, "a different corpus must invalidate and re-parse")
}
func TestCache_JSONMemoizesAndReportsError(t *testing.T) {
c := &Cache{}
obj1, err1 := c.JSONObject(`{"a":1}`)
obj2, err2 := c.JSONObject(`{"a":1}`)
require.NoError(t, err1)
require.NoError(t, err2)
require.Equal(t, obj1, obj2)
_, err := c.JSONObject(`{not-json`)
require.Error(t, err, "invalid JSON must surface the unmarshal error")
}
func TestCache_Lowered(t *testing.T) {
c := &Cache{}
require.Equal(t, "abc", c.Lowered("ABC"))
require.Equal(t, "abc", c.Lowered("ABC"), "second call hits the memoized result")
require.Equal(t, "xyz", c.Lowered("XYZ"), "different corpus re-lowers")
}
// TestCache_ConcurrentAccess exercises the mutex: a single cache instance hit
// from many goroutines (the interactsh re-evaluation shape) must not race or
// panic. Run with -race to validate.
func TestCache_ConcurrentAccess(t *testing.T) {
c := &Cache{}
const corpus = `<html><body><div id="a">x</div></body></html>`
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, _ = c.HTMLNode(corpus)
_, _ = c.JSONObject(`{"k":"v"}`)
_ = c.Lowered("MiXeD")
}()
}
wg.Wait()
}
+7 -2
View File
@@ -37,14 +37,19 @@ func (e *Extractor) CompileExtractors() error {
}
for _, query := range e.JSON {
query, err := gojq.Parse(query)
if cached, err := cache.JQ().GetIFPresent(query); err == nil && cached != nil {
e.jsonCompiled = append(e.jsonCompiled, cached)
continue
}
parsed, err := gojq.Parse(query)
if err != nil {
return fmt.Errorf("could not parse json: %s", query)
}
compiled, err := gojq.Compile(query)
compiled, err := gojq.Compile(parsed)
if err != nil {
return fmt.Errorf("could not compile json: %s", query)
}
_ = cache.JQ().Set(query, compiled)
e.jsonCompiled = append(e.jsonCompiled, compiled)
}
+63 -22
View File
@@ -6,6 +6,7 @@ import (
"github.com/antchfx/htmlquery"
"github.com/antchfx/xmlquery"
"golang.org/x/net/html"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
@@ -43,20 +44,37 @@ func (e *Extractor) ExtractRegex(corpus string) map[string]struct{} {
return results
}
// ExtractKval extracts key value pairs from a data map
// ExtractKval extracts key value pairs from a data map.
//
// For the case-insensitive variant we previously copied the entire input map
// and lower-cased every key plus every string value. This was O(event size)
// per extractor invocation regardless of how many KVal entries we actually
// queried (typically 1-3 keys against a map that can hold dozens of headers
// and full body fields). The new implementation scans the source map only
// for matches against the requested KVal entries via strings.EqualFold,
// then lower-cases hits in place. No event-wide copy.
func (e *Extractor) ExtractKval(data map[string]interface{}) map[string]struct{} {
results := make(map[string]struct{})
if e.CaseInsensitive {
inputData := data
data = make(map[string]interface{}, len(inputData))
for k, v := range inputData {
if s, ok := v.(string); ok {
v = strings.ToLower(s)
for _, k := range e.KVal {
for srcKey, srcVal := range data {
if !strings.EqualFold(srcKey, k) {
continue
}
itemString := types.ToString(srcVal)
if s, ok := srcVal.(string); ok {
itemString = strings.ToLower(s)
} else {
itemString = strings.ToLower(itemString)
}
if _, present := results[itemString]; !present {
results[itemString] = struct{}{}
}
}
data[strings.ToLower(k)] = v
}
return results
}
results := make(map[string]struct{})
for _, k := range e.KVal {
item, ok := data[k]
if !ok {
@@ -78,12 +96,22 @@ func (e *Extractor) ExtractXPath(corpus string) map[string]struct{} {
return e.ExtractHTML(corpus)
}
// ExtractHTML extracts items from HTML using XPath selectors
// ExtractHTML extracts items from HTML using XPath selectors. Parses the
// corpus on every call. Protocol callers with access to the per-response
// body cache should prefer ExtractHTMLNode.
func (e *Extractor) ExtractHTML(corpus string) map[string]struct{} {
results := make(map[string]struct{})
doc, err := htmlquery.Parse(strings.NewReader(corpus))
if err != nil {
return make(map[string]struct{})
}
return e.ExtractHTMLNode(doc)
}
// ExtractHTMLNode extracts items from a pre-parsed HTML node using XPath
// selectors.
func (e *Extractor) ExtractHTMLNode(doc *html.Node) map[string]struct{} {
results := make(map[string]struct{})
if doc == nil {
return results
}
for _, k := range e.XPath {
@@ -93,7 +121,6 @@ func (e *Extractor) ExtractHTML(corpus string) map[string]struct{} {
}
for _, node := range nodes {
var value string
if e.Attribute != "" {
value = htmlquery.SelectAttr(node, e.Attribute)
} else {
@@ -107,15 +134,24 @@ func (e *Extractor) ExtractHTML(corpus string) map[string]struct{} {
return results
}
// ExtractXML extracts items from XML using XPath selectors
// ExtractXML extracts items from XML using XPath selectors. Parses the
// corpus on every call. Protocol callers with access to the per-response
// body cache should prefer ExtractXMLNode.
func (e *Extractor) ExtractXML(corpus string) map[string]struct{} {
results := make(map[string]struct{})
doc, err := xmlquery.Parse(strings.NewReader(corpus))
if err != nil {
return make(map[string]struct{})
}
return e.ExtractXMLNode(doc)
}
// ExtractXMLNode extracts items from a pre-parsed XML node using XPath
// selectors.
func (e *Extractor) ExtractXMLNode(doc *xmlquery.Node) map[string]struct{} {
results := make(map[string]struct{})
if doc == nil {
return results
}
for _, k := range e.XPath {
nodes, err := xmlquery.QueryAll(doc, k)
if err != nil {
@@ -123,7 +159,6 @@ func (e *Extractor) ExtractXML(corpus string) map[string]struct{} {
}
for _, node := range nodes {
var value string
if e.Attribute != "" {
value = node.SelectAttr(e.Attribute)
} else {
@@ -137,16 +172,22 @@ func (e *Extractor) ExtractXML(corpus string) map[string]struct{} {
return results
}
// ExtractJSON extracts text from a corpus using JQ queries and returns it
// ExtractJSON extracts text from a corpus using JQ queries. Unmarshals on
// every call. Protocol callers with access to the per-response body cache
// should prefer ExtractJSONObject to share the unmarshaled value across
// multiple json extractors.
func (e *Extractor) ExtractJSON(corpus string) map[string]struct{} {
results := make(map[string]struct{})
var jsonObj interface{}
if err := json.Unmarshal([]byte(corpus), &jsonObj); err != nil {
return results
return make(map[string]struct{})
}
return e.ExtractJSONObject(jsonObj)
}
// ExtractJSONObject runs the compiled jq programs against an already
// unmarshaled JSON value.
func (e *Extractor) ExtractJSONObject(jsonObj interface{}) map[string]struct{} {
results := make(map[string]struct{})
for _, k := range e.jsonCompiled {
iter := k.Run(jsonObj)
for {
+40 -19
View File
@@ -7,8 +7,10 @@ import (
"github.com/antchfx/htmlquery"
"github.com/antchfx/xmlquery"
"github.com/projectdiscovery/govaluate"
"golang.org/x/net/html"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/operators/common/bodycache"
"github.com/projectdiscovery/nuclei/v3/pkg/operators/common/dsl"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/expressions"
stringsutil "github.com/projectdiscovery/utils/strings"
@@ -52,9 +54,22 @@ func (matcher *Matcher) MatchSize(length int) bool {
}
// MatchWords matches a word check against a corpus.
//
// For case-insensitive matchers we look up the lower-cased corpus in the
// per-response bodycache (when present) so that multiple word matchers
// against the same body avoid redundant ToLower allocations. Words
// themselves are lower-cased once at compile time.
func (matcher *Matcher) MatchWords(corpus string, data map[string]interface{}) (bool, []string) {
if matcher.CaseInsensitive {
corpus = strings.ToLower(corpus)
if data != nil {
if bc, ok := data[bodycache.Key].(*bodycache.Cache); ok && bc != nil {
corpus = bc.Lowered(corpus)
} else {
corpus = strings.ToLower(corpus)
}
} else {
corpus = strings.ToLower(corpus)
}
}
var matchedWords []string
@@ -253,25 +268,32 @@ func (matcher *Matcher) MatchXPath(corpus string) bool {
return matcher.MatchHTML(corpus)
}
// MatchHTML matches items from HTML using XPath selectors
// MatchHTML matches items from HTML using XPath selectors. Parses corpus on
// every call. Callers that already have a parsed node should prefer
// MatchHTMLNode to avoid redundant parses (see pkg/operators/common/bodycache).
func (matcher *Matcher) MatchHTML(corpus string) bool {
doc, err := htmlquery.Parse(strings.NewReader(corpus))
if err != nil {
return false
}
return matcher.MatchHTMLNode(doc)
}
// MatchHTMLNode matches items from a pre-parsed HTML node using XPath
// selectors. Used by the protocol Match dispatch in conjunction with the
// per-response body cache so multiple xpath matchers against the same
// response share one parse.
func (matcher *Matcher) MatchHTMLNode(doc *html.Node) bool {
if doc == nil {
return false
}
matches := 0
for _, k := range matcher.XPath {
nodes, err := htmlquery.QueryAll(doc, k)
if err != nil {
continue
}
// Continue if the xpath doesn't return any nodes
if len(nodes) == 0 {
// If we are in an AND request and a match failed,
// return false as the AND condition fails on any single mismatch.
switch matcher.condition {
case ANDCondition:
return false
@@ -279,36 +301,38 @@ func (matcher *Matcher) MatchHTML(corpus string) bool {
continue
}
}
// If the condition was an OR, return on the first match.
if matcher.condition == ORCondition && !matcher.MatchAll {
return true
}
matches = matches + len(nodes)
}
return matches > 0
}
// MatchXML matches items from XML using XPath selectors
// MatchXML matches items from XML using XPath selectors. Parses corpus on
// every call. Callers that already have a parsed node should prefer
// MatchXMLNode to avoid redundant parses.
func (matcher *Matcher) MatchXML(corpus string) bool {
doc, err := xmlquery.Parse(strings.NewReader(corpus))
if err != nil {
return false
}
return matcher.MatchXMLNode(doc)
}
// MatchXMLNode matches items from a pre-parsed XML node using XPath
// selectors.
func (matcher *Matcher) MatchXMLNode(doc *xmlquery.Node) bool {
if doc == nil {
return false
}
matches := 0
for _, k := range matcher.XPath {
nodes, err := xmlquery.QueryAll(doc, k)
if err != nil {
continue
}
// Continue if the xpath doesn't return any nodes
if len(nodes) == 0 {
// If we are in an AND request and a match failed,
// return false as the AND condition fails on any single mismatch.
switch matcher.condition {
case ANDCondition:
return false
@@ -316,14 +340,11 @@ func (matcher *Matcher) MatchXML(corpus string) bool {
continue
}
}
// If the condition was an OR, return on the first match.
if matcher.condition == ORCondition && !matcher.MatchAll {
return true
}
matches = matches + len(nodes)
}
return matches > 0
}
+57 -10
View File
@@ -1,6 +1,8 @@
package output
import (
"sync"
"github.com/logrusorgru/aurora/v4"
)
@@ -30,22 +32,67 @@ func (mw *MultiWriter) Colorizer() *aurora.Aurora {
return aurora.New(aurora.WithColors(false))
}
// Write fans out the event to all underlying writers concurrently. The local
// file/stdout writer typically completes in microseconds while remote writers
// (e.g. PDCP upload) can take hundreds of milliseconds; running them serially
// gates the fast path on the slowest writer. With concurrent fan-out the
// caller waits at most max(writer latencies) instead of sum.
//
// Behavior change vs. previous implementation: errors no longer short-circuit
// the remaining writers. All writers are attempted; the first observed error
// is returned. This is intentional: a transient PDCP upload failure should
// not silently drop the local file/json result.
func (mw *MultiWriter) Write(event *ResultEvent) error {
for _, writer := range mw.writers {
if err := writer.Write(event); err != nil {
return err
}
if len(mw.writers) == 1 {
return mw.writers[0].Write(event)
}
return nil
var (
mu sync.Mutex
firstErr error
wg sync.WaitGroup
)
wg.Add(len(mw.writers))
for _, w := range mw.writers {
go func(w Writer) {
defer wg.Done()
if err := w.Write(event); err != nil {
mu.Lock()
if firstErr == nil {
firstErr = err
}
mu.Unlock()
}
}(w)
}
wg.Wait()
return firstErr
}
// WriteFailure mirrors Write semantics: fan-out and collect the first error.
func (mw *MultiWriter) WriteFailure(event *InternalWrappedEvent) error {
for _, writer := range mw.writers {
if err := writer.WriteFailure(event); err != nil {
return err
}
if len(mw.writers) == 1 {
return mw.writers[0].WriteFailure(event)
}
return nil
var (
mu sync.Mutex
firstErr error
wg sync.WaitGroup
)
wg.Add(len(mw.writers))
for _, w := range mw.writers {
go func(w Writer) {
defer wg.Done()
if err := w.WriteFailure(event); err != nil {
mu.Lock()
if firstErr == nil {
firstErr = err
}
mu.Unlock()
}
}(w)
}
wg.Wait()
return firstErr
}
func (mw *MultiWriter) Request(templateID, url, requestType string, err error) {
+99
View File
@@ -0,0 +1,99 @@
package expressions
import (
"strings"
"sync"
"github.com/projectdiscovery/gcache"
"github.com/projectdiscovery/govaluate"
"github.com/projectdiscovery/nuclei/v3/pkg/operators/cache"
"github.com/projectdiscovery/nuclei/v3/pkg/operators/common/dsl"
)
// badExprCacheCap caps the negative-result cache (expressions that fail to compile).
// Keeping it small bounds memory while still preventing repeated parse work for
// pathological input that appears repeatedly in the same scan.
const badExprCacheCap = 2048
// bareExprCacheCap caps the cache for govaluate.NewEvaluableExpression (no helper functions).
// This is hit from isExpression which discriminates whether a {{...}} substring is a valid
// govaluate expression. Hot in DSL-heavy templates.
const bareExprCacheCap = 4096
var (
badExprOnce sync.Once
badExprCache gcache.Cache[string, struct{}]
bareExprOnce sync.Once
bareExprCache gcache.Cache[string, *govaluate.EvaluableExpression]
)
func badExpr() gcache.Cache[string, struct{}] {
badExprOnce.Do(func() {
badExprCache = gcache.New[string, struct{}](badExprCacheCap).LRU().Build()
})
return badExprCache
}
func bareExpr() gcache.Cache[string, *govaluate.EvaluableExpression] {
bareExprOnce.Do(func() {
bareExprCache = gcache.New[string, *govaluate.EvaluableExpression](bareExprCacheCap).LRU().Build()
})
return bareExprCache
}
// compileExprWithFunctions compiles a govaluate expression with the DSL helper
// function table, caching both successes (in the shared dsl cache) and
// failures (in a small local negative cache). Callers in this package should
// use this helper instead of calling govaluate.NewEvaluableExpressionWithFunctions
// directly so per-request hot paths do not pay parse cost on every invocation.
func compileExprWithFunctions(src string) (*govaluate.EvaluableExpression, error) {
c := cache.DSL()
if compiled, err := c.GetIFPresent(src); err == nil && compiled != nil {
return compiled, nil
}
if _, err := badExpr().GetIFPresent(src); err == nil {
// Known-bad: re-run the parser to surface the original error message.
// This is rare; the cache prevents the common case of repeated parse work.
return govaluate.NewEvaluableExpressionWithFunctions(src, dsl.HelperFunctions)
}
compiled, err := govaluate.NewEvaluableExpressionWithFunctions(src, dsl.HelperFunctions)
if err != nil {
_ = badExpr().Set(src, struct{}{})
return nil, err
}
_ = c.Set(src, compiled)
return compiled, nil
}
// compileBareExpr compiles a govaluate expression without DSL helpers, used by
// isExpression to discriminate between bare expressions and helper-function
// invocations. Cached separately because it has different parse semantics
// from the with-functions variant (functions are unknown identifiers).
func compileBareExpr(src string) (*govaluate.EvaluableExpression, error) {
c := bareExpr()
if compiled, err := c.GetIFPresent(src); err == nil && compiled != nil {
return compiled, nil
}
compiled, err := govaluate.NewEvaluableExpression(src)
if err != nil {
return nil, err
}
_ = c.Set(src, compiled)
return compiled, nil
}
// baseHasAnyKey reports whether any key in base appears as a substring in
// data. Replaces an O(len(base)) []string allocation that getFunctionsNames
// previously incurred on every isExpression call.
func baseHasAnyKey(data string, base map[string]interface{}) bool {
for k := range base {
if k == "" {
continue
}
if strings.Contains(data, k) {
return true
}
}
return false
}
@@ -14,7 +14,7 @@ import (
// Eval compiles the given expression and evaluate it with the given values preserving the return type
func Eval(expression string, values map[string]interface{}) (interface{}, error) {
compiled, err := govaluate.NewEvaluableExpressionWithFunctions(expression, dsl.HelperFunctions)
compiled, err := compileExprWithFunctions(expression)
if err != nil {
return nil, err
}
@@ -58,7 +58,7 @@ func evaluate(data string, base map[string]interface{}) (string, error) {
expression = replacer.Replace(expression, base)
// turns expressions (either helper functions+base values or base values)
compiled, err := govaluate.NewEvaluableExpressionWithFunctions(expression, dsl.HelperFunctions)
compiled, err := compileExprWithFunctions(expression)
if err != nil {
return data, fmt.Errorf("failed to compile expression %q: %w", originalExpression, err)
}
@@ -154,15 +154,15 @@ func isExpression(data string, base map[string]interface{}) bool {
return false
}
if _, err := govaluate.NewEvaluableExpression(data); err == nil {
if stringsutil.ContainsAny(data, getFunctionsNames(base)...) {
if _, err := compileBareExpr(data); err == nil {
if baseHasAnyKey(data, base) {
return true
} else if stringsutil.ContainsAny(data, dsl.FunctionNames...) {
return true
}
return false
}
_, err := govaluate.NewEvaluableExpressionWithFunctions(data, dsl.HelperFunctions)
_, err := compileExprWithFunctions(data)
return err == nil
}
@@ -196,11 +196,3 @@ func unresolvedVarMarkers(vars []string, base map[string]any) string {
}
return strings.Join(markers, "")
}
func getFunctionsNames(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
@@ -4,9 +4,6 @@ import (
"errors"
"regexp"
"strings"
"github.com/projectdiscovery/govaluate"
"github.com/projectdiscovery/nuclei/v3/pkg/operators/common/dsl"
)
var (
@@ -116,7 +113,7 @@ func ContainsVariablesWithIgnoreList(skipNames map[string]interface{}, items ...
}
func hasLiteralsOnly(data string) bool {
expr, err := govaluate.NewEvaluableExpressionWithFunctions(data, dsl.HelperFunctions)
expr, err := compileExprWithFunctions(data)
if err != nil {
return false
}
+53 -25
View File
@@ -5,43 +5,71 @@ import (
"reflect"
)
// MergeMapsMany merges many maps into a new map
// MergeMapsMany merges many maps into a new map.
//
// Fast paths handle the two map shapes the codebase actually passes:
// map[string][]string and map[string]interface{}. Other shapes fall back to
// the reflect-based implementation. This avoids per-key reflect.Value
// allocation on the HTTP operator-callback hot path
// (pkg/protocols/http/request.go) which fires on every successful response.
func MergeMapsMany(maps ...interface{}) map[string][]string {
m := make(map[string][]string)
appendToSlice := func(key, value string) {
m[key] = append(m[key], value)
}
for _, gotMap := range maps {
val := reflect.ValueOf(gotMap)
if val.Kind() != reflect.Map {
continue
}
appendToSlice := func(key, value string) {
if values, ok := m[key]; !ok {
m[key] = []string{value}
} else {
m[key] = append(values, value)
switch typed := gotMap.(type) {
case map[string][]string:
for k, vs := range typed {
m[k] = append(m[k], vs...)
}
}
for _, e := range val.MapKeys() {
v := val.MapIndex(e)
switch v.Kind() {
case reflect.Slice, reflect.Array:
for i := 0; i < v.Len(); i++ {
appendToSlice(e.String(), v.Index(i).String())
}
case reflect.String:
appendToSlice(e.String(), v.String())
case reflect.Interface:
switch data := v.Interface().(type) {
case map[string]interface{}:
for k, v := range typed {
switch data := v.(type) {
case string:
appendToSlice(e.String(), data)
appendToSlice(k, data)
case []string:
for _, value := range data {
appendToSlice(e.String(), value)
appendToSlice(k, value)
}
}
}
case map[string]string:
for k, v := range typed {
appendToSlice(k, v)
}
default:
mergeMapsManyReflect(m, gotMap, appendToSlice)
}
}
return m
}
func mergeMapsManyReflect(m map[string][]string, gotMap interface{}, appendToSlice func(string, string)) {
val := reflect.ValueOf(gotMap)
if val.Kind() != reflect.Map {
return
}
for _, e := range val.MapKeys() {
v := val.MapIndex(e)
switch v.Kind() {
case reflect.Slice, reflect.Array:
for i := 0; i < v.Len(); i++ {
appendToSlice(e.String(), v.Index(i).String())
}
case reflect.String:
appendToSlice(e.String(), v.String())
case reflect.Interface:
switch data := v.Interface().(type) {
case string:
appendToSlice(e.String(), data)
case []string:
for _, value := range data {
appendToSlice(e.String(), value)
}
}
}
}
return m
}
// MergeMaps merges multiple maps into a new map.
+73 -6
View File
@@ -1,6 +1,7 @@
package replacer
import (
"io"
"strings"
"github.com/projectdiscovery/fasttemplate"
@@ -10,14 +11,29 @@ import (
)
// Replace replaces placeholders in template with values on the fly.
//
// This avoids the previous behavior of eagerly stringifying every value in the
// map for each call (which allocated a copy of the map on every per-request
// evaluate path) and skips the second-pass `§§` substitution when the marker
// is not present in the input.
func Replace(template string, values map[string]interface{}) string {
valuesMap := make(map[string]interface{}, len(values))
for k, v := range values {
valuesMap[k] = types.ToString(v)
// Fast path: no placeholders at all (cheaper than fasttemplate's own check
// because it avoids constructing the closure).
hasParenOpen := strings.Contains(template, marker.ParenthesisOpen)
hasGeneral := strings.Contains(template, marker.General)
if !hasParenOpen && !hasGeneral {
return template
}
replaced := fasttemplate.ExecuteStringStd(template, marker.ParenthesisOpen, marker.ParenthesisClose, valuesMap)
final := fasttemplate.ExecuteStringStd(replaced, marker.General, marker.General, valuesMap)
return final
if hasParenOpen {
tagFn := makeStdTagFunc(values, marker.ParenthesisOpen, marker.ParenthesisClose)
template = fasttemplate.ExecuteFuncString(template, marker.ParenthesisOpen, marker.ParenthesisClose, tagFn)
}
if hasGeneral {
gtagFn := makeStdTagFunc(values, marker.General, marker.General)
template = fasttemplate.ExecuteFuncString(template, marker.General, marker.General, gtagFn)
}
return template
}
// Replace replaces one placeholder in template with one value on the fly.
@@ -30,3 +46,54 @@ func ReplaceOne(template string, key string, value interface{}) string {
func replaceOneWithMarkers(template, key string, value interface{}, openMarker, closeMarker string) string {
return strings.Replace(template, openMarker+key+closeMarker, types.ToString(value), 1)
}
// makeStdTagFunc returns a fasttemplate.TagFunc that lazily resolves tags
// against the supplied map and stringifies hits on demand. Unknown tags are
// re-emitted with their original markers, mirroring fasttemplate's
// keepUnknownTagFunc behavior. This avoids allocating an intermediate
// `map[string]interface{}` and avoids eagerly stringifying values that the
// template never references.
func makeStdTagFunc(values map[string]interface{}, startTag, endTag string) fasttemplate.TagFunc {
return func(w io.Writer, tag string) (int, error) {
// Honor fasttemplate's nested startTag handling: if the tag itself
// contains another startTag (e.g. "foo{{bar"), emit the literal
// "{{" + leading prefix and only resolve the trailing portion. This
// matches keepUnknownTagFunc semantics so behavior is preserved.
if i := strings.LastIndex(tag, startTag); i >= 0 {
if _, err := io.WriteString(w, startTag); err != nil {
return 0, err
}
if _, err := io.WriteString(w, tag[:i]); err != nil {
return 0, err
}
tag = tag[i+len(startTag):]
}
v, ok := values[tag]
if !ok {
n, err := io.WriteString(w, startTag)
if err != nil {
return n, err
}
n2, err := io.WriteString(w, tag)
n += n2
if err != nil {
return n, err
}
n3, err := io.WriteString(w, endTag)
return n + n3, err
}
if v == nil {
return 0, nil
}
switch value := v.(type) {
case []byte:
return w.Write(value)
case string:
return io.WriteString(w, value)
case fasttemplate.TagFunc:
return value(w, tag)
default:
return io.WriteString(w, types.ToString(v))
}
}
}
+11 -3
View File
@@ -116,29 +116,37 @@ func (gr *generatedRequest) setReqURLPattern(reqURLPattern string) {
}
// ApplyAuth applies the auth provider to the generated request
func (g *generatedRequest) ApplyAuth(provider authprovider.AuthProvider) {
// ApplyAuth applies any matching auth strategies onto the generated request
// and reports whether the request was potentially mutated (i.e. at least one
// strategy matched). Callers use the return value to decide whether a request
// dump taken before this call is still accurate.
func (g *generatedRequest) ApplyAuth(provider authprovider.AuthProvider) bool {
if provider == nil {
return
return false
}
var applied bool
if g.request != nil {
authStrategies := provider.LookupURLX(g.request.URL)
for _, strategy := range authStrategies {
strategy.ApplyOnRR(g.request)
applied = true
}
}
if g.rawRequest != nil {
parsed, err := urlutil.ParseAbsoluteURL(g.rawRequest.FullURL, true)
if err != nil {
gologger.Warning().Msgf("[authprovider] Could not parse URL %s: %s\n", g.rawRequest.FullURL, err)
return
return applied
}
authStrategies := provider.LookupURLX(parsed)
// here we need to apply it custom because we don't have a standard/official
// rawhttp request format ( which we probably should have )
for _, strategy := range authStrategies {
g.rawRequest.ApplyAuthStrategy(strategy)
applied = true
}
}
return applied
}
func (g *generatedRequest) URL() string {
+15 -8
View File
@@ -22,6 +22,7 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/expressions"
"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/protocolstate"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http/httpclientpool"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/network/networkclientpool"
@@ -140,14 +141,16 @@ type Request struct {
CompiledOperators *operators.Operators `yaml:"-" json:"-"`
options *protocols.ExecutorOptions
connConfiguration *httpclientpool.Configuration
totalRequests int
customHeaders map[string]string
generator *generators.PayloadGenerator // optional, only enabled when using payloads
httpClient *retryablehttp.Client
rawhttpClient *rawhttp.Client
dialer *fastdialer.Dialer
options *protocols.ExecutorOptions
connConfiguration *httpclientpool.Configuration
totalRequests int
customHeaders map[string]string
generator *generators.PayloadGenerator // optional, only enabled when using payloads
httpClient *retryablehttp.Client
rawhttpClient *rawhttp.Client
dialer *fastdialer.Dialer
hasInteractMatchers bool // cached interactsh.HasMatchers(CompiledOperators) computed at Compile time
needsRequestEvent bool // cached hasInteractMatchers && NeedsRequestCondition()
// description: |
// SelfContained specifies if the request is self-contained.
@@ -398,6 +401,10 @@ func (request *Request) Compile(options *protocols.ExecutorOptions) error {
}
request.CompiledOperators = compiled
}
// Cache interactsh.HasMatchers / NeedsRequestCondition results so per-request
// hot paths skip the linear matcher/extractor scan (interactsh.go ~374-394).
request.hasInteractMatchers = interactsh.HasMatchers(request.CompiledOperators)
request.needsRequestEvent = request.hasInteractMatchers && request.NeedsRequestCondition()
// === fuzzing filters ===== //
+17 -2
View File
@@ -40,6 +40,21 @@ type StopAtFirstMatchHandler[T comparable] struct {
maxResults int
}
// resultChanBuffer returns the result channel buffer for a SPM handler given
// the pool size. We size to the pool so workers can deposit results without
// blocking when the consumer goroutine is briefly slow. Bounded above by 64
// to keep memory predictable for very large pools.
func resultChanBuffer(size int) int {
switch {
case size <= 1:
return 1
case size > 64:
return 64
default:
return size
}
}
// NewBlockingSPMHandler creates a new stop at first match handler
func NewBlockingSPMHandler[T comparable](ctx context.Context, size int, maxResults int, spm bool) *StopAtFirstMatchHandler[T] {
ctx1, cancel := context.WithCancel(ctx)
@@ -47,7 +62,7 @@ func NewBlockingSPMHandler[T comparable](ctx context.Context, size int, maxResul
awg, _ := syncutil.New(syncutil.WithSize(size))
s := &StopAtFirstMatchHandler[T]{
ResultChan: make(chan T, 1),
ResultChan: make(chan T, resultChanBuffer(size)),
poolType: Blocking,
sgPool: awg,
internalWg: &sync.WaitGroup{},
@@ -66,7 +81,7 @@ func NewBlockingSPMHandler[T comparable](ctx context.Context, size int, maxResul
func NewNonBlockingSPMHandler[T comparable](ctx context.Context, maxResults int, spm bool) *StopAtFirstMatchHandler[T] {
ctx1, cancel := context.WithCancel(ctx)
s := &StopAtFirstMatchHandler[T]{
ResultChan: make(chan T, 1),
ResultChan: make(chan T, 16),
poolType: NonBlocking,
wgPool: &sync.WaitGroup{},
internalWg: &sync.WaitGroup{},
+37 -3
View File
@@ -8,6 +8,7 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/model"
"github.com/projectdiscovery/nuclei/v3/pkg/operators"
"github.com/projectdiscovery/nuclei/v3/pkg/operators/common/bodycache"
"github.com/projectdiscovery/nuclei/v3/pkg/operators/extractors"
"github.com/projectdiscovery/nuclei/v3/pkg/operators/matchers"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
@@ -43,7 +44,21 @@ func (request *Request) Match(data map[string]interface{}, matcher *matchers.Mat
case matchers.DSLMatcher:
return matcher.Result(matcher.MatchDSL(data)), []string{}
case matchers.XPathMatcher:
return matcher.Result(matcher.MatchXPath(item)), []string{}
// Share the parsed HTML/XML tree across all xpath matchers + extractors
// targeting the same response via bodycache.
cache := bodycache.From(data)
if strings.HasPrefix(item, "<?xml") {
doc, err := cache.XMLNode(item)
if err != nil {
return false, []string{}
}
return matcher.Result(matcher.MatchXMLNode(doc)), []string{}
}
doc, err := cache.HTMLNode(item)
if err != nil {
return false, []string{}
}
return matcher.Result(matcher.MatchHTMLNode(doc)), []string{}
}
return false, []string{}
}
@@ -72,9 +87,28 @@ func (request *Request) Extract(data map[string]interface{}, extractor *extracto
case extractors.KValExtractor:
return extractor.ExtractKval(data)
case extractors.XPathExtractor:
return extractor.ExtractXPath(item)
// Share parsed HTML/XML across xpath extractors and matchers via bodycache.
cache := bodycache.From(data)
if strings.HasPrefix(item, "<?xml") {
doc, err := cache.XMLNode(item)
if err != nil {
return make(map[string]struct{})
}
return extractor.ExtractXMLNode(doc)
}
doc, err := cache.HTMLNode(item)
if err != nil {
return make(map[string]struct{})
}
return extractor.ExtractHTMLNode(doc)
case extractors.JSONExtractor:
return extractor.ExtractJSON(item)
// Share unmarshaled JSON value across json extractors via bodycache.
cache := bodycache.From(data)
obj, err := cache.JSONObject(item)
if err != nil {
return make(map[string]struct{})
}
return extractor.ExtractJSONObject(obj)
case extractors.DSLExtractor:
return extractor.ExtractDSL(data)
}
+23 -11
View File
@@ -269,8 +269,9 @@ func (request *Request) executeParallelHTTP(input *contextargs.Context, dynamicV
continue
}
request.options.RateLimitTake()
hasInteractMatchers := interactsh.HasMatchers(request.CompiledOperators)
needsRequestEvent := hasInteractMatchers && request.NeedsRequestCondition()
// Cached at Compile time; avoids walking the matcher/extractor graph per worker iteration.
hasInteractMatchers := request.hasInteractMatchers
needsRequestEvent := request.needsRequestEvent
select {
case <-spmHandler.Done():
spmHandler.Release()
@@ -531,7 +532,8 @@ func (request *Request) ExecuteWithResults(input *contextargs.Context, dynamicVa
for {
// returns two values, error and skip, which skips the execution for the request instance.
executeFunc := func(data string, payloads, dynamicValue map[string]interface{}) (bool, error) {
hasInteractMatchers := interactsh.HasMatchers(request.CompiledOperators)
// Cached at Compile time.
hasInteractMatchers := request.hasInteractMatchers
request.options.RateLimitTake()
@@ -569,7 +571,8 @@ func (request *Request) ExecuteWithResults(input *contextargs.Context, dynamicVa
execReqErr := request.executeRequest(input, generatedHttpRequest, previous, hasInteractMatchers, func(event *output.InternalWrappedEvent) {
// a special case where operators has interactsh matchers and multiple request are made
// ex: status_code_2 , interactsh_protocol (from 1st request) etc
needsRequestEvent := interactsh.HasMatchers(request.CompiledOperators) && request.NeedsRequestCondition()
// Cached at Compile time.
needsRequestEvent := request.needsRequestEvent
if (hasInteractMarkers || needsRequestEvent) && request.options.Interactsh != nil {
requestData := &interactsh.RequestData{
MakeResultFunc: request.MakeResultEvent,
@@ -753,8 +756,9 @@ func (request *Request) executeRequest(input *contextargs.Context, generatedRequ
}
// === apply auth strategies ===
var authApplied bool
if generatedRequest.request != nil && !request.SkipSecretFile {
generatedRequest.ApplyAuth(request.options.AuthProvider)
authApplied = generatedRequest.ApplyAuth(request.options.AuthProvider)
}
var formedURL string
@@ -871,15 +875,23 @@ func (request *Request) executeRequest(input *contextargs.Context, generatedRequ
// converts whitespace and other chars that cannot be printed to url encoded values
formedURL = urlutil.URLEncodeWithEscapes(formedURL)
// Dump the requests containing all headers
if !generatedRequest.original.Race {
var dumpError error
dumpedRequest, dumpError = dump(generatedRequest, input.MetaInput.Input)
// Re-dump the request after auth so the value carried into the result
// event (and any debug/store output) reflects headers applied by
// ApplyAuth(). The pre-flight dump above runs before auth, so we must
// refresh dumpedRequest whenever auth actually mutated the request.
//
// The dump is expensive, so when auth did not touch the request we reuse
// the pre-flight dump and only re-run it when a debug/store consumer
// needs to emit it.
needsDebugDump := request.options.Options.Debug || request.options.Options.DebugRequests || request.options.Options.StoreResponse
if !generatedRequest.original.Race && (authApplied || needsDebugDump) {
postAuthDump, dumpError := dump(generatedRequest, input.MetaInput.Input)
if dumpError != nil {
return dumpError
}
dumpedRequestString := string(dumpedRequest)
if request.options.Options.Debug || request.options.Options.DebugRequests || request.options.Options.StoreResponse {
dumpedRequest = postAuthDump
if needsDebugDump {
dumpedRequestString := string(dumpedRequest)
msg := fmt.Sprintf("[%s] Dumped HTTP request for %s\n\n", request.options.TemplateID, formedURL)
if request.options.Options.Debug || request.options.Options.DebugRequests {
+469
View File
@@ -0,0 +1,469 @@
# Nuclei v4 — Architectural Direction
This is a working design note, not a release plan. It captures the structural
changes that justify a major-version bump for nuclei — the ones that an
additive performance branch (like `feat-performance-improvements`) cannot
accomplish because they break the public surface, the on-disk format, the
SDK, or the template schema.
The criterion for inclusion is deliberately strict: a change earns a slot
here only if it is **breaking** *and* unlocks a step-change (5–10× perf, a
new capability, or removal of a load-bearing constraint). Incremental wins
belong in feature branches against `dev`.
## Why v4 (one paragraph)
v3 has accumulated load-bearing constraints that show up as friction every
time the engine is touched: the event model is a typed-erased
`map[string]interface{}` shared by every protocol, every matcher and every
extractor; templates are re-parsed from YAML on every startup; worker pools
are created per template; reflection sits on the HTTP success callback path;
the binary is monolithic at ~170 MB. Patching around these costs 1.2–2× at
best (which is what the perf branch achieved). Removing them — and shipping
the new contract that comes with their removal — is what v4 is for.
---
## #1 — Typed event model (kill `map[string]interface{}`)
**What changes.** Replace `output.InternalEvent = map[string]interface{}`
with a typed `Event` struct exposing the load-bearing fields directly:
```go
type Event struct {
URL *url.URL
Method string
StatusCode int
Headers http.Header
Body []byte
Duration time.Duration
Request []byte // raw dump
Response []byte // raw dump
TemplateID string
TemplateInfo model.Info
Matched string
Extracts map[string]any // user-defined extracts only
Cache *eventcache.Cache // body decode cache (today: bodycache)
}
```
Matchers, extractors and DSL helpers gain typed accessors over this struct.
Untyped storage stays only for user-declared `Extracts`.
**Why it's breaking.** SDK consumers read `wrapped.InternalEvent[k]`
everywhere. DSL helper functions accept and return `interface{}`.
`MakeResultEvent` is part of every protocol. JSON output schema for
`-jsonl` would change.
**What it unlocks.**
- The replacer no longer needs `types.ToString(v)` per access.
- The body-decode cache stops being keyed by a magic string in a generic map
(`__nuclei_bodycache`) and becomes a real field.
- `MergeMaps`, `MergeMapsMany`, and the entire reflect path collapse into
typed merges of `Extracts`.
- Body becomes `[]byte` not `string`, eliminating the implicit copy at
`string(body)`.
- Matchers can take a `*Event` instead of separately-constructed `corpus`
and `data` arguments — the `getMatchPart` shim disappears.
- DSL gets typed function signatures (`StatusCode int`, not `interface{}`).
**Complexity.** High. Touches every protocol package and the DSL function
registry. Estimated as the single biggest refactor in v4.
**Prereqs.** None — this is a leaf change. Other v4 items get cleaner if
this lands first.
---
## #2 — Compiled template artifacts (Template IR + `nuclei compile`)
**What changes.** Introduce a binary template bundle format:
```
nuclei compile -t templates/ -o templates.nucbin
nuclei -bundle templates.nucbin -u https://...
```
The bundle contains:
- Pre-parsed template ASTs (no YAML at runtime).
- Pre-compiled regex, jq programs, DSL expressions.
- Pre-verified template signatures (with the verifier identity recorded).
- An index over IDs, tags, severity, protocols, paths.
- A version header (`bundle_format: 1`) and a `nuclei_version` constraint.
Today's directory-based loading remains supported as a fallback for local
development.
**Why it's breaking.** The bundle format becomes part of the public API.
Reproducibility expectations change — pinning a bundle hash pins behavior.
CI flows shift from "fetch templates repo at SHA" to "fetch versioned
bundle artifact."
**What it unlocks.**
- Cold start collapses from `O(N × YAML parse + verify + compile)` to
`mmap + version check`. Expect 10–50× on large catalogs.
- Bundles can be signed once and distributed via PDCP / OCI / S3.
- Long-lived nuclei services (DAST server, scheduled scanners) stop paying
the parse cost on every spawn.
- Deterministic, hash-pinned scans become trivial — bundle hash + target
list = full provenance.
**Complexity.** Medium-high. The IR design is the hard part; the rest is
serialization plumbing.
**Prereqs.** Cleaner with #1 (typed event lets the IR avoid embedding any
`interface{}` placeholders for runtime types).
---
## #3 — Persistent work-stealing scheduler
**What changes.** Replace the per-template worker pool model in
`pkg/core/executors.go` with one global scheduler over typed task tuples:
```go
type Task struct {
Template *Template
Target *Target
Payload *Payload
Cluster ClusterID // for cross-template request dedup
}
```
The scheduler has work-stealing queues per worker, adaptive concurrency
tied to observed RTT and CPU load, first-class backpressure with bounded
queue depth, and a single shared HTTP client pool.
**Why it's breaking.** Resume semantics change (the `ResumeCfg.InFlight`
bookkeeping is per-template today). The `Engine` API exposed via
`pkg/core/` is not stable across this change. SPM (stop-at-first-match)
handlers move from per-request to per-cluster.
**What it unlocks.**
- Cross-template request dedup beyond opt-in clustering.
- Cache locality on hot HTTP clients (no more pool teardown per template).
- Tail-latency-aware throttling (slow targets get fewer workers).
- The buffered-tasks-channel patch from `feat-performance-improvements`
becomes a non-event — backpressure is built in.
**Complexity.** High. Engine rewrite.
**Prereqs.** Easier with #1 (typed event makes task carrying simpler).
Composes well with #2 (bundle gives the scheduler a static shape to plan
against).
---
## #4 — Streaming I/O end-to-end
**What changes.**
- **Input:** target list streamed reservoir-style from stdin/file/socket;
no full pre-load. Memory cost decoupled from `wc -l targets`.
- **Output:** lock-free SPSC ring per writer; on Linux, optional io_uring
backend for the file writer. Today's `MultiWriter` fan-out (already added
in `feat-performance-improvements`) becomes redundant.
- **Bodies:** chunked-body matchers where the operator allows it (regex,
word, binary). HTML/XML/JSON still need full body for parsing.
- **Project cache:** drop LevelDB. The current goroutine dump shows 5+ live
goroutines (`tCompaction`, `mCompaction`, `mpoolDrain`, buffer pool drain)
for the entire run, even when nothing is stored. Replace with `bbolt`
(already a transitive dep) or a tiny purpose-built KV.
**Why it's breaking.** Project file format changes (different KV backend).
Result ordering guarantees may change (lock-free rings break strict order).
Writer plugins must adopt the new interface.
**What it unlocks.**
- Order-of-magnitude reduction in resident memory on big scans.
- Removal of an entire class of "engine is busy doing GC on goleveldb"
background noise.
**Complexity.** Medium. Mostly mechanical once the writer interface
changes.
**Prereqs.** None.
---
## #5 — WASM extension ABI; demote Goja
**What changes.** Add a wasmtime/wazero ABI for matchers, extractors,
post-processors, and (eventually) protocols. Templates can declare a
WASM-backed step:
```yaml
matchers:
- type: wasm
module: classifiers/login-page.wasm:v1
params: {min_score: 0.85}
```
Goja remains as a compatibility shim for `code:` / `javascript:` templates
with deprecated status. New templates target WASM.
**Why it's breaking.** Sandbox model changes. The implicit "JS code can
shell out to anything Goja exposes" contract is replaced by an explicit
capability list per WASM module. Some `code:` templates won't be portable.
**What it unlocks.**
- Deterministic CPU/memory limits per extension call.
- Extensions in any language that targets WASM (Rust, Zig, AssemblyScript,
TinyGo).
- A safe place to run inference modules (see #8).
- Removes a class of "Goja crashes the runtime" tickets.
**Complexity.** Medium. Wasmtime/wazero embedding is well-understood;
designing the ABI surface is the design work.
**Prereqs.** None directly; pairs naturally with #6 (modular protocols).
---
## #6 — Modular protocols, slim default binary
**What changes.** The default `nuclei` binary ships only HTTP, DNS, TCP,
SSL — the 90%-of-users core. Headless (Chrome deps), Code, JavaScript,
Fuzz, Network-extras, WebSocket, WHOIS, File become separately-distributed
modules loaded at runtime.
Module loading uses the WASM ABI from #5 where possible (lighter weight,
sandboxed) or a minimal Go plugin protocol over Unix-domain RPC for
modules that need full Go-runtime access (today: headless).
**Why it's breaking.** Installation changes (`nuclei install headless`).
Templates referencing missing protocols error explicitly instead of
silently working. Distribution becomes per-module versioned.
**What it unlocks.**
- Default binary drops from ~170 MB to ~30–40 MB.
- Independent release cadence per protocol.
- Smaller blast radius for protocol-specific CVEs in dependencies.
- Container images shrink significantly (Chrome alone is the bulk of the
headless bundle).
**Complexity.** Medium-high. The plugin loader and the module distribution
infrastructure are the work.
**Prereqs.** #5 makes most of this trivial; some protocols (headless)
likely need the Go-plugin escape hatch.
---
## #7 — Explicit template schema versioning
**What changes.** Promote `template-schema:` to a load-bearing field. The
v4 engine compiles v3 templates through a translation layer to v4 IR.
Schema evolution after v4 follows a documented deprecation process.
```yaml
id: foo
template-schema: 4
info: {...}
http: [...]
```
**Why it's breaking.** Sets the precedent that template schema and engine
version are independently versioned. Some v3-only fields may not have v4
equivalents and require flagging.
**What it unlocks.**
- Users can upgrade nuclei without a flag-day for the entire templates
repo.
- Schema can move forward without backward-compat ratchets baked into the
parser.
**Complexity.** Low for the field itself; medium for the v3→v4 translator.
**Prereqs.** Has to land *before* any other schema-affecting change in v4.
---
## #8 — Optional onboard inference
**Status: addon, not core.** Inference is a v4-era feature because it adds
non-deterministic-by-default behavior to a tool whose contract has always
been deterministic. Treat it as opt-in everywhere, locally-runnable by
default, and architected so a scan run without it is unchanged from today.
### Where it earns its keep
1. **False-positive triage as a post-matcher.** A small on-device
classifier (ONNX MiniLM-class, ~30 MB) runs *only on already-matched
responses*, scoring `(template_id, status_code, body_excerpt,
header_subset) → likely_fp`. Doesn't change the truth output (matched
stays matched); adds a confidence score and an optional
`--fp-threshold` filter. Highest leverage, lowest risk.
2. **A new `ai:` matcher / extractor type.** Sits alongside
`word`/`regex`/`dsl`/`xpath`:
```yaml
matchers:
- type: ai
model: minilm-classifier:v1
prompt: is_login_page
threshold: 0.8
```
Outputs are cached by `(model_version, prompt_hash, body_hash)`. The
body-decode cache added in `feat-performance-improvements` is the right
place for this — extend it with a `Classify(label, body) (bool,
float32, error)` slot so multiple `ai:` matchers against the same
response share one inference call.
3. **Natural-language template search and synthesis as tooling, not
runtime.** New CLI verbs:
- `nuclei explain <template-id>` — readable summary.
- `nuclei find "reflected XSS via Referer header"` — embedding search
over the catalog.
- `nuclei draft -from-response request.txt response.txt` — generate
template skeleton.
Pure tooling; doesn't touch the scan engine.
4. **Context-aware fuzz payload mutation.** A small generator conditioned
on parameter name/type/observed reflection produces candidates that
complement the static dictionary fuzzing. Capped per request to avoid
throughput collapse.
5. **Tech-stack inference for unknown fingerprints.** A cheap classifier
over headers + body fingerprints enriches `tags`/`metadata` when
wappalyzergo doesn't know the stack. Feeds into `-as` (auto-scan).
### What makes it non-trivial in nuclei specifically
- **Determinism is load-bearing.** Today you can re-run a scan and assert
identical output — CI, audit, regression all rely on it. Inference
breaks this unless we pin: model identity, weights hash, temperature=0,
and result cache. Hard contract for v4: `(nuclei_version, model_pin,
cached_response_bytes) → identical matcher verdict`. Anything else is
opt-in via a clearly labeled flag, and the result event records that
inference contributed.
- **Latency budget per response is microseconds.** A model invoked on
every body would gut throughput. Two mitigations:
- Run inference *only* on already-matched responses (FP triage), so
scaling is `O(matches)` not `O(requests)`.
- Cache by body hash so multi-template clusters hit the model once.
- **Body content is hostile input.** Prompt injection against a local
model is a real concern; against a tool-using NL CLI it's worse. Treat
model input as untrusted, never let response content control tool-use
paths, and run inference inside the WASM sandbox from #5.
- **Binary size and dependency creep.** ONNX-runtime and llama.cpp via cgo
are heavy. This is exactly why #6 (modular protocols) matters — `nuclei
ai-pack` becomes an opt-in module, default binary stays slim.
- **Local vs. remote provider abstraction.** Same interface, two backends:
- Embedded (default, deterministic, offline): ONNX/llama.cpp with
version-pinned tiny models bundled into the `ai-pack` module.
- Remote (opt-in, visible cost): PDCP-hosted, OpenAI-compatible, etc.
Budget controls (`--ai-budget 1000-tokens`, `--ai-budget 0.05-USD`)
and explicit per-template enablement.
### Honest limits
- **Don't ship a generative LLM in the default binary.** Embeddings + small
classifiers, yes; a 7B-parameter model, no — that lives in
`nuclei-ai-pack`.
- **Don't pitch CVE discovery via LLM-only matchers.** Inference is a
triage and tooling layer over deterministic matchers, not a replacement
for them. The "zero false positives" pitch collapses the moment a
probabilistic matcher is the only signal for a finding.
- **Don't let inference results auto-mutate templates.** `nuclei draft` is
a starting point for humans; auto-promotion is out of scope.
### How it fits the rest of v4
- Sits on top of #1 (typed event gives the model structured features
instead of stringly-typed fields).
- Bakes model pins into #2 (compiled bundle records `model_version` so a
scan is reproducible).
- Runs inside #5 (WASM ABI sandboxes the inference module and enforces
capability limits).
- Distributed as an optional module per #6.
---
## Prioritization
If only two land, pick #1 and #2:
- **#1 (typed event)** clears the path for #3 (scheduler), #4 (streaming),
#5 (WASM ABI), and #8 (inference) — every other item gets cheaper.
- **#2 (compiled artifacts)** delivers the most user-visible win on its
own (cold start) and unlocks reproducible deployments + signed bundle
distribution.
Sequencing for the rest:
```
#7 (schema versioning) ─── must land first
↓
#1 (typed event) ─── biggest refactor
↓
┌──────────┴─────────┐
#2 (IR/bundle) #4 (streaming I/O)
↓ ↓
#3 (scheduler) #6 (modular protocols)
↓
#5 (WASM ABI)
↓
#8 (inference)
```
#5 → #8 is the canonical AI-as-sandboxed-module path. #6 can land before
#5 with a Go-plugin loader as a stop-gap.
---
## Non-goals (explicitly *not* v4-worthy)
- Replacing Goja with QuickJS / Boa / a different JS runtime — incremental.
- HTTP/3 support — protocol-level addition, fits in dev.
- Better workflow language — incremental.
- Better error messages — incremental.
- A Web UI — out of scope for the engine; belongs to a separate product.
- LLM-generated templates as a default behavior — see Honest limits in #8.
---
## Risks / open questions
- **#1 SDK churn.** Heaviest external impact. Need a clean migration shim
for the `lib/` SDK consumers — probably an adapter that constructs a
`*Event` from a `map[string]any` for one release, with a deprecation
flag.
- **#2 signature model.** Today templates are signed individually. A
bundle introduces a new signing question: per-template signatures
carried in the bundle, or a single bundle signature? Probably both
(per-template for provenance, bundle for distribution integrity).
- **#3 backpressure visibility.** A persistent scheduler hides per-template
throughput from the user. Need first-class metrics (per-template QPS,
pool occupancy) to keep operators happy.
- **#5 ABI stability.** Once external WASM modules ship, the ABI is
effectively a public API. Versioning strategy must be specified before
the first module is published.
- **#6 module distribution.** Where do modules live? PDCP hosting? OCI
registry? Plain HTTPS download with manifests? Decision affects supply
chain trust model.
- **#8 inference reproducibility.** Even with temperature=0 and pinned
weights, inference engines occasionally diverge across CPU
microarchitectures (different SIMD paths, different rounding). Need a
bit-exact reference implementation or accept a documented
"reproducible-modulo-architecture" caveat.
---
## What's already in flight (informs sequencing)
- **`feat-performance-improvements`** (this branch): 10 additive
optimizations targeting v3 hot paths. Median 2.22× on a representative
bench. Establishes the body-decode cache that #8 reuses, the typed
`MergeMapsMany` paths that #1 generalizes, and the buffered-channel /
concurrent-writer wins that #3 / #4 subsume entirely.
- **In-flight community PRs** (referenced during the perf pass): RFC 9111
HTTP response cache, scan strategy improvements, additional protocol
hardening. None of these block the v4 items above; most either slot
into the v3 line or compose cleanly with v4.