Compare commits

...
Author SHA1 Message Date
Mzack9999 a2bd54cb66 Absolute offsets 2026-10-26 12:11:17 +01:00
Mzack9999 c6c4e054ca Merge dev 2026-10-26 12:04:13 +01:00
Mzack9999 8002ba3395 Merge dev 2026-10-26 08:14:58 +01:00
Mzack9999 5182bf6a8e Merge origin 2026-10-26 08:14:58 +01:00
Mzack9999 606e1a6567 fix offsets 2026-09-24 20:37:31 +02:00
Mzack9999 16740c6e74 merge dev 2026-09-24 15:40:05 +02:00
Mzack9999 6b08a393d1 fix regex 2026-09-24 00:50:53 +02:00
Mzack9999 3132b20ba1 fix offsets 2026-09-23 21:58:13 +02:00
Mzack9999 3836892f8a merge dev 2026-09-23 20:34:33 +02:00
Mzack9999 98b28ca24e merge dev 2026-09-14 22:32:43 +02:00
Mzack9999 dc29347909 filesize offset 2026-07-23 21:53:23 +04:00
10 changed files with 603 additions and 65 deletions

No files matched your search

+36 -6
View File
@@ -44,16 +44,19 @@ func (matcher *Matcher) CompileMatchers() error {
// Compile the regexes (with shared cache)
for _, regex := range matcher.Regex {
if cached, err := cache.Regex().GetIFPresent(regex); err == nil && cached != nil {
matcher.regexCompiled = append(matcher.regexCompiled, cached)
continue
}
compiled, err := regexp.Compile(regex)
compiled, err := compileRegex(regex)
if err != nil {
return fmt.Errorf("could not compile regex: %s", regex)
}
_ = cache.Regex().Set(regex, compiled)
matcher.regexCompiled = append(matcher.regexCompiled, compiled)
if matcher.Offset != nil {
offsetCompiled, err := compileOffsetRegex(regex, *matcher.Offset)
if err != nil {
return fmt.Errorf("could not compile regex: %s", regex)
}
matcher.offsetRegexCompiled = append(matcher.offsetRegexCompiled, offsetCompiled)
}
}
// Compile and validate binary Values in matcher
@@ -100,6 +103,33 @@ func (matcher *Matcher) CompileMatchers() error {
return nil
}
// compileRegex compiles pattern through the shared regex cache
func compileRegex(pattern string) (*regexp.Regexp, error) {
if cached, err := cache.Regex().GetIFPresent(pattern); err == nil && cached != nil {
return cached, nil
}
compiled, err := regexp.Compile(pattern)
if err != nil {
return nil, err
}
_ = cache.Regex().Set(pattern, compiled)
return compiled, nil
}
// compileOffsetRegex compiles the variant of pattern used when a matcher is
// pinned to a byte offset. The pattern is anchored so a single match attempt
// answers whether it matches at the requested position, and for offsets past
// the start of the corpus it also consumes the rune preceding the offset. That
// leading rune is matched against the real bytes of the corpus, so ^, \A, \b
// and \B keep seeing the context they would see during a normal scan.
func compileOffsetRegex(pattern string, offset int) (*regexp.Regexp, error) {
anchored := `\A(?:` + pattern + `)`
if offset > 0 {
anchored = `\A(?s:.)(?:` + pattern + `)`
}
return compileRegex(anchored)
}
// GetType returns the condition type of the matcher
// todo: the field should be exposed natively
func (matcher *Matcher) GetCondition() ConditionType {
+147 -31
View File
@@ -2,7 +2,9 @@ package matchers
import (
"os"
"regexp"
"strings"
"unicode/utf8"
"github.com/antchfx/htmlquery"
"github.com/antchfx/xmlquery"
@@ -60,7 +62,14 @@ func (matcher *Matcher) MatchWords(corpus string, data map[string]interface{}) (
// MatchWordsWithOptions matches words with the current scan's DSL network policy.
func (matcher *Matcher) MatchWordsWithOptions(corpus string, data map[string]interface{}, options *types.Options) (bool, []string) {
if matcher.CaseInsensitive {
return matcher.MatchWordsAt(corpus, data, options, 0)
}
// MatchWordsAt matches words against a corpus that starts at byte position
// base of the full response, so Offset stays absolute when the response is
// matched in chunks.
func (matcher *Matcher) MatchWordsAt(corpus string, data map[string]interface{}, options *types.Options, base int) (bool, []string) {
if matcher.CaseInsensitive && matcher.Offset == nil {
corpus = strings.ToLower(corpus)
}
@@ -84,7 +93,11 @@ func (matcher *Matcher) MatchWordsWithOptions(corpus string, data map[string]int
}
}
// Continue if the word doesn't match
if !strings.Contains(corpus, word) {
matched := matcher.matchStringAt(corpus, word, base)
if matcher.CaseInsensitive && matcher.Offset != nil {
matched = matcher.matchFoldStringAt(corpus, word, base)
}
if !matched {
// 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 {
@@ -114,36 +127,18 @@ func (matcher *Matcher) MatchWordsWithOptions(corpus string, data map[string]int
// MatchRegex matches a regex check against a corpus
func (matcher *Matcher) MatchRegex(corpus string) (bool, []string) {
return matcher.MatchRegexAt(corpus, 0)
}
// MatchRegexAt matches regexes against a corpus that starts at byte position
// base of the full response, so Offset stays absolute when the response is
// matched in chunks.
func (matcher *Matcher) MatchRegexAt(corpus string, base int) (bool, []string) {
var matchedRegexes []string
// Iterate over all the regexes accepted as valid
for i, regex := range matcher.regexCompiled {
// Literal prefix short-circuit
rstr := regex.String()
if !strings.Contains(rstr, "(?i") { // covers (?i) and (?i:
if prefix, ok := regex.LiteralPrefix(); ok && prefix != "" {
if !strings.Contains(corpus, prefix) {
switch matcher.condition {
case ANDCondition:
return false, []string{}
case ORCondition:
continue
}
}
}
}
// Fast OR-path: return first match without full scan
if matcher.condition == ORCondition && !matcher.MatchAll {
m := regex.FindAllString(corpus, 1)
if len(m) == 0 {
continue
}
return true, m
}
// Single scan: get all matches directly
currentMatches := regex.FindAllString(corpus, -1)
if len(currentMatches) == 0 {
currentMatches, matched := matcher.findRegexMatches(corpus, i, regex, base)
if !matched {
switch matcher.condition {
case ANDCondition:
return false, []string{}
@@ -152,7 +147,10 @@ func (matcher *Matcher) MatchRegex(corpus string) (bool, []string) {
}
}
// If the condition was an OR (and MatchAll true), we still need to gather all
// If the condition was an OR, return on the first match.
if matcher.condition == ORCondition && !matcher.MatchAll {
return true, currentMatches
}
matchedRegexes = append(matchedRegexes, currentMatches...)
// If we are at the end of the regex, return with true
@@ -166,12 +164,98 @@ func (matcher *Matcher) MatchRegex(corpus string) (bool, []string) {
return false, []string{}
}
func (matcher *Matcher) findRegexMatches(corpus string, index int, regex *regexp.Regexp, base int) ([]string, bool) {
if matcher.Offset != nil {
return matcher.findRegexMatchAtOffset(corpus, index, regex, *matcher.Offset-base)
}
// Literal prefix short-circuit
rstr := regex.String()
if !strings.Contains(rstr, "(?i") { // covers (?i) and (?i:
if prefix, ok := regex.LiteralPrefix(); ok && prefix != "" {
if !strings.Contains(corpus, prefix) {
return nil, false
}
}
}
if matcher.condition == ORCondition && !matcher.MatchAll {
m := regex.FindAllString(corpus, 1)
if len(m) == 0 {
return nil, false
}
return m, true
}
currentMatches := regex.FindAllString(corpus, -1)
if len(currentMatches) == 0 {
return nil, false
}
return currentMatches, true
}
// findRegexMatchAtOffset reports whether the regex has a match starting exactly
// at offset, which is matcher.Offset made relative to the corpus. Scanning the corpus for every match is both wasteful and
// wrong here: leftmost matching hides overlapping candidates, so a regex like
// `ab|b` against `ab` never reports the `b` starting at offset 1. Instead the
// anchored variant of the regex is run once over the corpus suffix that starts
// one rune before the offset, which keeps ^, \A and word boundaries evaluated
// against the original surrounding bytes.
func (matcher *Matcher) findRegexMatchAtOffset(corpus string, index int, regex *regexp.Regexp, offset int) ([]string, bool) {
if offset < 0 || offset > len(corpus) {
return nil, false
}
anchored := matcher.offsetRegex(index, regex, offset)
if anchored == nil {
return nil, false
}
start := offset
if offset > 0 {
_, backward := utf8.DecodeLastRuneInString(corpus[:offset])
start = offset - backward
// a match can only begin on a rune boundary, so an offset landing
// inside a multi byte rune never matches
if _, forward := utf8.DecodeRuneInString(corpus[start:]); forward != backward {
return nil, false
}
}
loc := anchored.FindStringIndex(corpus[start:])
if loc == nil {
return nil, false
}
return []string{corpus[offset : start+loc[1]]}, true
}
// offsetRegex returns the anchored variant of the regex at the given index,
// compiling it on demand when the matcher was not compiled with an offset or
// when the relative offset needs the other variant (offset 0 in a later chunk).
func (matcher *Matcher) offsetRegex(index int, regex *regexp.Regexp, offset int) *regexp.Regexp {
sameVariant := matcher.Offset != nil && (*matcher.Offset > 0) == (offset > 0)
if sameVariant && index >= 0 && index < len(matcher.offsetRegexCompiled) {
return matcher.offsetRegexCompiled[index]
}
compiled, err := compileOffsetRegex(regex.String(), offset)
if err != nil {
return nil
}
return compiled
}
// MatchBinary matches a binary check against a corpus
func (matcher *Matcher) MatchBinary(corpus string) (bool, []string) {
return matcher.MatchBinaryAt(corpus, 0)
}
// MatchBinaryAt matches binary values against a corpus that starts at byte
// position base of the full response, so Offset stays absolute when the
// response is matched in chunks.
func (matcher *Matcher) MatchBinaryAt(corpus string, base int) (bool, []string) {
var matchedBinary []string
// Iterate over all the words accepted as valid
for i, binary := range matcher.binaryDecoded {
if !strings.Contains(corpus, binary) {
if !matcher.matchStringAt(corpus, binary, base) {
// 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 {
@@ -197,6 +281,38 @@ func (matcher *Matcher) MatchBinary(corpus string) (bool, []string) {
return false, []string{}
}
// matchStringAt reports whether needle is present in corpus, optionally pinned
// to matcher.Offset when that field is set. base is the position of corpus in
// the full response.
func (matcher *Matcher) matchStringAt(corpus, needle string, base int) bool {
if matcher.Offset == nil {
return strings.Contains(corpus, needle)
}
offset := *matcher.Offset - base
if offset < 0 || offset > len(corpus) || offset+len(needle) > len(corpus) {
return false
}
return corpus[offset:offset+len(needle)] == needle
}
// matchFoldStringAt compares the same number of Unicode code points at the
// configured byte offset without lowercasing the corpus and shifting offsets.
func (matcher *Matcher) matchFoldStringAt(corpus, needle string, base int) bool {
offset := *matcher.Offset - base
if offset < 0 || offset > len(corpus) {
return false
}
end := offset
for range needle {
if end == len(corpus) {
return false
}
_, size := utf8.DecodeRuneInString(corpus[end:])
end += size
}
return strings.EqualFold(corpus[offset:end], needle)
}
// MatchDSL matches on a generic map result
func (matcher *Matcher) MatchDSL(data map[string]interface{}) bool {
return matcher.MatchDSLWithOptions(data, nil)
+203
View File
@@ -1,6 +1,7 @@
package matchers
import (
"strings"
"testing"
"github.com/projectdiscovery/govaluate"
@@ -435,6 +436,208 @@ func TestMatchWords_CaseInsensitive_DynamicValue(t *testing.T) {
require.Equal(t, []string{"example.com"}, matched)
}
func TestMatchOffset(t *testing.T) {
offset0 := 0
offset2 := 2
t.Run("word at start", func(t *testing.T) {
m := &Matcher{Type: MatcherTypeHolder{MatcherType: WordsMatcher}, Words: []string{"MZ"}, Offset: &offset0}
require.NoError(t, m.CompileMatchers())
ok, snippets := m.MatchWords("MZ....", nil)
require.True(t, ok)
require.Equal(t, []string{"MZ"}, snippets)
ok, _ = m.MatchWords("xMZ...", nil)
require.False(t, ok)
})
t.Run("word at mid offset", func(t *testing.T) {
m := &Matcher{Type: MatcherTypeHolder{MatcherType: WordsMatcher}, Words: []string{"AB"}, Offset: &offset2}
require.NoError(t, m.CompileMatchers())
ok, _ := m.MatchWords("xxAByy", nil)
require.True(t, ok)
ok, _ = m.MatchWords("ABxxxx", nil)
require.False(t, ok)
ok, _ = m.MatchWords("xx--AB", nil)
require.False(t, ok)
})
t.Run("case insensitive word preserves byte offset", func(t *testing.T) {
m := &Matcher{
Type: MatcherTypeHolder{MatcherType: WordsMatcher},
Words: []string{"mz"},
CaseInsensitive: true,
Offset: &offset2,
}
require.NoError(t, m.CompileMatchers())
ok, snippets := m.MatchWords("İMZ", nil)
require.True(t, ok)
require.Equal(t, []string{"mz"}, snippets)
})
t.Run("binary at start", func(t *testing.T) {
m := &Matcher{Type: MatcherTypeHolder{MatcherType: BinaryMatcher}, Binary: []string{"4d5a"}, Offset: &offset0}
require.NoError(t, m.CompileMatchers())
ok, _ := m.MatchBinary("MZ....")
require.True(t, ok)
ok, _ = m.MatchBinary("xMZ...")
require.False(t, ok)
})
t.Run("binary at mid offset", func(t *testing.T) {
m := &Matcher{Type: MatcherTypeHolder{MatcherType: BinaryMatcher}, Binary: []string{"4d5a"}, Offset: &offset2}
require.NoError(t, m.CompileMatchers())
ok, _ := m.MatchBinary("xxMZ....")
require.True(t, ok)
ok, _ = m.MatchBinary("xx--MZ")
require.False(t, ok)
})
t.Run("regex must start at offset", func(t *testing.T) {
m := &Matcher{Type: MatcherTypeHolder{MatcherType: RegexMatcher}, Regex: []string{"MZ"}, Offset: &offset0}
require.NoError(t, m.CompileMatchers())
ok, snippets := m.MatchRegex("MZPE")
require.True(t, ok)
require.Equal(t, []string{"MZ"}, snippets)
ok, _ = m.MatchRegex("xxMZ")
require.False(t, ok)
})
t.Run("regex preserves assertion context", func(t *testing.T) {
m := &Matcher{Type: MatcherTypeHolder{MatcherType: RegexMatcher}, Regex: []string{"^MZ"}, Offset: &offset2}
require.NoError(t, m.CompileMatchers())
ok, _ := m.MatchRegex("xxMZ")
require.False(t, ok)
})
t.Run("negative offset rejected", func(t *testing.T) {
neg := -1
m := &Matcher{Type: MatcherTypeHolder{MatcherType: WordsMatcher}, Words: []string{"MZ"}, Offset: &neg}
require.Error(t, m.CompileMatchers())
})
}
func newOffsetRegexMatcher(t *testing.T, offset int, regexes ...string) *Matcher {
t.Helper()
m := &Matcher{Type: MatcherTypeHolder{MatcherType: RegexMatcher}, Regex: regexes, Offset: &offset}
require.NoError(t, m.CompileMatchers())
return m
}
func TestMatchRegexOffset(t *testing.T) {
tests := []struct {
name string
regex string
corpus string
offset int
matched bool
snippet string
}{
{name: "overlapping alternative at offset", regex: "ab|b", corpus: "ab", offset: 1, matched: true, snippet: "b"},
{name: "leftmost alternative at offset", regex: "ab|b", corpus: "ab", offset: 0, matched: true, snippet: "ab"},
{name: "match before offset is ignored", regex: "ab", corpus: "abab", offset: 1, matched: false},
{name: "match after offset is ignored", regex: "b", corpus: "ab", offset: 0, matched: false},
{name: "greedy match keeps full span", regex: "a+", corpus: "baaa", offset: 1, matched: true, snippet: "aaa"},
{name: "start of text anchor at offset zero", regex: "^MZ", corpus: "MZxx", offset: 0, matched: true, snippet: "MZ"},
{name: "start of text anchor mid corpus", regex: "^MZ", corpus: "xxMZ", offset: 2, matched: false},
{name: "absolute start anchor mid corpus", regex: `\AMZ`, corpus: "xxMZ", offset: 2, matched: false},
{name: "multiline anchor after newline", regex: "(?m)^MZ", corpus: "xx\nMZ", offset: 3, matched: true, snippet: "MZ"},
{name: "multiline anchor without newline", regex: "(?m)^MZ", corpus: "xxMZ", offset: 2, matched: false},
{name: "word boundary after word character", regex: `\bfoo`, corpus: "xfoo", offset: 1, matched: false},
{name: "word boundary after separator", regex: `\bfoo`, corpus: " foo", offset: 1, matched: true, snippet: "foo"},
{name: "non word boundary inside word", regex: `\Bar`, corpus: "bar", offset: 1, matched: true, snippet: "ar"},
{name: "non word boundary after separator", regex: `\Bar`, corpus: "-ar", offset: 1, matched: false},
{name: "end of text anchor honored", regex: "b$", corpus: "ab", offset: 1, matched: true, snippet: "b"},
{name: "end of text anchor rejected", regex: "b$", corpus: "abc", offset: 1, matched: false},
{name: "case insensitive flag preserved", regex: "(?i)mz", corpus: "xxMZ", offset: 2, matched: true, snippet: "MZ"},
{name: "offset inside multi byte rune", regex: "MZ", corpus: "éMZ", offset: 1, matched: false},
{name: "offset after multi byte rune", regex: "MZ", corpus: "éMZ", offset: 2, matched: true, snippet: "MZ"},
{name: "offset past corpus", regex: "MZ", corpus: "MZ", offset: 5, matched: false},
{name: "zero width match at corpus end", regex: "x*", corpus: "ab", offset: 2, matched: true, snippet: ""},
{name: "invalid utf8 byte as context", regex: "MZ", corpus: "\xffMZ", offset: 1, matched: true, snippet: "MZ"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
m := newOffsetRegexMatcher(t, test.offset, test.regex)
ok, snippets := m.MatchRegex(test.corpus)
require.Equal(t, test.matched, ok)
if test.matched {
require.Equal(t, []string{test.snippet}, snippets)
}
})
}
t.Run("multiple regexes keep their anchored variant", func(t *testing.T) {
offset := 1
m := &Matcher{
Type: MatcherTypeHolder{MatcherType: RegexMatcher},
Regex: []string{"b", "bc"},
Condition: "and",
Offset: &offset,
}
require.NoError(t, m.CompileMatchers())
ok, snippets := m.MatchRegex("abc")
require.True(t, ok)
require.Equal(t, []string{"b", "bc"}, snippets)
ok, _ = m.MatchRegex("xbc")
require.True(t, ok)
ok, _ = m.MatchRegex("bcx")
require.False(t, ok)
})
t.Run("anchored variant compiled on demand", func(t *testing.T) {
m := newOffsetRegexMatcher(t, 1, "ab|b")
m.offsetRegexCompiled = nil
ok, snippets := m.MatchRegex("ab")
require.True(t, ok)
require.Equal(t, []string{"b"}, snippets)
})
t.Run("allocations do not grow with corpus size", func(t *testing.T) {
m := newOffsetRegexMatcher(t, 4, "a{3}")
small := strings.Repeat("a", 1<<10)
large := strings.Repeat("a", 1<<20)
smallAllocs := testing.AllocsPerRun(20, func() { _, _ = m.MatchRegex(small) })
largeAllocs := testing.AllocsPerRun(20, func() { _, _ = m.MatchRegex(large) })
require.LessOrEqual(t, largeAllocs, smallAllocs+1, "offset matching should not allocate per corpus match")
require.LessOrEqual(t, largeAllocs, float64(8), "offset matching should allocate a constant amount")
})
}
func BenchmarkMatchRegexOffset(b *testing.B) {
dense := strings.Repeat("ab", 1<<19)
benchmarks := []struct {
name string
regex string
corpus string
offset int
}{
{name: "match at start of dense corpus", regex: "ab", corpus: dense, offset: 0},
{name: "overlapping alternative", regex: "ab|b", corpus: dense, offset: 1},
{name: "match near end of dense corpus", regex: "ab", corpus: dense, offset: len(dense) - 2},
{name: "no match in dense corpus", regex: "zz", corpus: dense, offset: 3},
}
for _, bm := range benchmarks {
b.Run(bm.name, func(b *testing.B) {
offset := bm.offset
m := &Matcher{Type: MatcherTypeHolder{MatcherType: RegexMatcher}, Regex: []string{bm.regex}, Offset: &offset}
if err := m.CompileMatchers(); err != nil {
b.Fatal(err)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = m.MatchRegex(bm.corpus)
}
})
}
}
func TestMatcher_MatchDSL_ErrorHandling(t *testing.T) {
// First expression errors (division by zero), second is true
m := &Matcher{Type: MatcherTypeHolder{MatcherType: DSLMatcher}, Condition: "or", DSL: []string{"1 / 0", "1 == 1"}}
+14 -2
View File
@@ -105,6 +105,15 @@ type Matcher struct {
// []string{"//a[@target=\"_blank\"]"}
XPath []string `yaml:"xpath,omitempty" json:"xpath,omitempty" jsonschema:"title=xpath queries to match in response,description=xpath are the XPath queries that will be evaluated against the response part of nuclei matching rules"`
// description: |
// Offset is an optional byte index where the matcher must match.
// When set, word/binary values must appear exactly at this offset, and
// regex matches must start at this offset. When omitted, matching uses
// the default anywhere-in-corpus behavior.
// examples:
// - name: Match MZ magic at start of file
// value: "0"
Offset *int `yaml:"offset,omitempty" json:"offset,omitempty" jsonschema:"title=byte offset to match at,description=Optional byte offset where the matcher must match"`
// description: |
// Prompt is the natural-language question for an llm matcher. The model
// judges the selected response part and returns a verdict; the matcher
// fires when the verdict equals Expect with at least MinConfidence.
@@ -179,8 +188,11 @@ type Matcher struct {
matcherType MatcherType
binaryDecoded []string
regexCompiled []*regexp.Regexp
dslCompiled []*govaluate.EvaluableExpression
llmClient LLMClient
// offsetRegexCompiled holds the anchored variant of each regex used when
// Offset is set, in the same order as regexCompiled
offsetRegexCompiled []*regexp.Regexp
dslCompiled []*govaluate.EvaluableExpression
llmClient LLMClient
}
// ConditionType is the type of condition for matcher
+6 -3
View File
@@ -47,13 +47,13 @@ func (matcher *Matcher) Validate() error {
expectedFields = append(commonExpectedFields, "Size", "Part")
case WordsMatcher:
requiredField, valueCount = "words", len(matcher.Words)
expectedFields = append(commonExpectedFields, "Words", "Part", "Encoding", "CaseInsensitive")
expectedFields = append(commonExpectedFields, "Words", "Part", "Encoding", "CaseInsensitive", "Offset")
case BinaryMatcher:
requiredField, valueCount = "binary", len(matcher.Binary)
expectedFields = append(commonExpectedFields, "Binary", "Part", "Encoding", "CaseInsensitive")
expectedFields = append(commonExpectedFields, "Binary", "Part", "Encoding", "CaseInsensitive", "Offset")
case RegexMatcher:
requiredField, valueCount = "regex", len(matcher.Regex)
expectedFields = append(commonExpectedFields, "Regex", "Part", "Encoding", "CaseInsensitive")
expectedFields = append(commonExpectedFields, "Regex", "Part", "Encoding", "CaseInsensitive", "Offset")
case XPathMatcher:
requiredField, valueCount = "xpath", len(matcher.XPath)
expectedFields = append(commonExpectedFields, "XPath", "Part")
@@ -70,6 +70,9 @@ func (matcher *Matcher) Validate() error {
return err
}
if matcher.Offset != nil && *matcher.Offset < 0 {
return fmt.Errorf("offset must be >= 0 (got %d)", *matcher.Offset)
}
if requiredField != "" && valueCount == 0 {
return fmt.Errorf("%s matcher requires at least one %s value", matcher.matcherType, requiredField)
}
+5
View File
@@ -34,6 +34,10 @@ var (
chunkSize, _ = units.FromHumanSize("100Mb")
)
// chunkStartKey holds the byte position of the current chunk in the file, so
// matcher offsets are measured from the start of the file
const chunkStartKey = "file-chunk-start"
// Request contains a File matching mechanism for local disk operations.
type Request struct {
// Operators for the current request go here.
@@ -119,6 +123,7 @@ var RequestPartDefinitions = map[string]string{
"matched": "Matched is the input which was matched upon",
"path": "Path is the path of file on local filesystem",
"type": "Type is the type of request made",
"filesize": "Size of the file on disk in bytes",
"raw,body,all,data": "Raw contains the raw file contents",
}
+8 -4
View File
@@ -20,15 +20,18 @@ func (request *Request) Match(data map[string]interface{}, matcher *matchers.Mat
return false, []string{}
}
// chunkStart keeps matcher offsets absolute when the file is read in chunks
chunkStart, _ := data[chunkStartKey].(int)
switch matcher.GetType() {
case matchers.SizeMatcher:
return matcher.Result(matcher.MatchSize(len(itemStr))), []string{}
case matchers.WordsMatcher:
return matcher.ResultWithMatchedSnippet(matcher.MatchWordsWithOptions(itemStr, nil, request.options.GetOptions()))
return matcher.ResultWithMatchedSnippet(matcher.MatchWordsAt(itemStr, nil, request.options.GetOptions(), chunkStart))
case matchers.RegexMatcher:
return matcher.ResultWithMatchedSnippet(matcher.MatchRegex(itemStr))
return matcher.ResultWithMatchedSnippet(matcher.MatchRegexAt(itemStr, chunkStart))
case matchers.BinaryMatcher:
return matcher.ResultWithMatchedSnippet(matcher.MatchBinary(itemStr))
return matcher.ResultWithMatchedSnippet(matcher.MatchBinaryAt(itemStr, chunkStart))
case matchers.DSLMatcher:
return matcher.Result(matcher.MatchDSLWithOptions(data, request.options.GetOptions())), []string{}
case matchers.XPathMatcher:
@@ -79,12 +82,13 @@ func (request *Request) getMatchPart(part string, data output.InternalEvent) (st
}
// responseToDSLMap converts a file chunk elaboration to a map for use in DSL matching
func (request *Request) responseToDSLMap(raw, inputFilePath, matchedFileName string) output.InternalEvent {
func (request *Request) responseToDSLMap(raw, inputFilePath, matchedFileName string, fileSize int64) output.InternalEvent {
return output.InternalEvent{
"path": inputFilePath,
"matched": matchedFileName,
"raw": raw,
"type": request.Type().String(),
"filesize": fileSize,
"template-id": request.options.TemplateID,
"template-info": request.options.TemplateInfo,
"template-path": request.options.TemplatePath,
+11 -10
View File
@@ -49,9 +49,10 @@ func TestResponseToDSLMap(t *testing.T) {
require.Nil(t, err, "could not compile file request")
resp := "test-data\r\n"
event := request.responseToDSLMap(resp, "one.one.one.one", "one.one.one.one")
require.Len(t, event, 7, "could not get correct number of items in dsl map")
event := request.responseToDSLMap(resp, "one.one.one.one", "one.one.one.one", 11)
require.Len(t, event, 8, "could not get correct number of items in dsl map")
require.Equal(t, resp, event["raw"], "could not get correct resp")
require.Equal(t, int64(11), event["filesize"])
}
func TestFileOperatorMatch(t *testing.T) {
@@ -75,8 +76,8 @@ func TestFileOperatorMatch(t *testing.T) {
require.Nil(t, err, "could not compile file request")
resp := "test-data\r\n1.1.1.1\r\n"
event := request.responseToDSLMap(resp, "one.one.one.one", "one.one.one.one")
require.Len(t, event, 7, "could not get correct number of items in dsl map")
event := request.responseToDSLMap(resp, "one.one.one.one", "one.one.one.one", 11)
require.Len(t, event, 8, "could not get correct number of items in dsl map")
require.Equal(t, resp, event["raw"], "could not get correct resp")
t.Run("valid", func(t *testing.T) {
@@ -124,8 +125,8 @@ func TestFileOperatorMatch(t *testing.T) {
t.Run("caseInsensitive", func(t *testing.T) {
resp := "TEST-DATA\r\n1.1.1.1\r\n"
event := request.responseToDSLMap(resp, "one.one.one.one", "one.one.one.one")
require.Len(t, event, 7, "could not get correct number of items in dsl map")
event := request.responseToDSLMap(resp, "one.one.one.one", "one.one.one.one", 11)
require.Len(t, event, 8, "could not get correct number of items in dsl map")
require.Equal(t, resp, event["raw"], "could not get correct resp")
matcher := &matchers.Matcher{
@@ -164,8 +165,8 @@ func TestFileOperatorExtract(t *testing.T) {
require.Nil(t, err, "could not compile file request")
resp := "test-data\r\n1.1.1.1\r\n"
event := request.responseToDSLMap(resp, "one.one.one.one", "one.one.one.one")
require.Len(t, event, 7, "could not get correct number of items in dsl map")
event := request.responseToDSLMap(resp, "one.one.one.one", "one.one.one.one", 11)
require.Len(t, event, 8, "could not get correct number of items in dsl map")
require.Equal(t, resp, event["raw"], "could not get correct resp")
t.Run("extract", func(t *testing.T) {
@@ -283,8 +284,8 @@ func testFileMakeResult(t *testing.T, matchers []*matchers.Matcher, matcherCondi
matchedFileName := "test.txt"
fileContent := "test-data\r\n1.1.1.1\r\n"
event := request.responseToDSLMap(fileContent, "/tmp", matchedFileName)
require.Len(t, event, 7, "could not get correct number of items in dsl map")
event := request.responseToDSLMap(fileContent, "/tmp", matchedFileName, 11)
require.Len(t, event, 8, "could not get correct number of items in dsl map")
require.Equal(t, fileContent, event["raw"], "could not get correct resp")
finalEvent := &output.InternalWrappedEvent{InternalEvent: event}
+15 -8
View File
@@ -258,7 +258,7 @@ func (request *Request) processReader(reader io.Reader, filePath string, input *
}
// build event structure to interface with internal logic
return request.buildEvent(input.MetaInput.Input, filePath, fileMatches, opResult, previousInternalEvent), fileMatches, nil
return request.buildEvent(input.MetaInput.Input, filePath, totalBytes, fileMatches, opResult, previousInternalEvent), fileMatches, nil
}
func (request *Request) findMatchesWithReader(reader io.Reader, input *contextargs.Context, filePath string, totalBytes int64, previous output.InternalEvent) ([]FileMatch, *operators.Result) {
@@ -269,9 +269,13 @@ func (request *Request) findMatchesWithReader(reader io.Reader, input *contextar
// we are forced to check if the whole file needs to be elaborated
// - matchers-condition option set to AND
hasAndCondition := request.CompiledOperators.GetMatchersCondition() == matchers.ANDCondition
// - any matcher has AND condition
hasOffsetMatcher := false
// - any matcher has AND condition or an absolute offset
for _, matcher := range request.CompiledOperators.Matchers {
if hasAndCondition {
if matcher.Offset != nil {
hasOffsetMatcher = true
}
if hasAndCondition && hasOffsetMatcher {
break
}
if matcher.GetCondition() == matchers.ANDCondition {
@@ -281,11 +285,13 @@ func (request *Request) findMatchesWithReader(reader io.Reader, input *contextar
scanner := bufio.NewScanner(reader)
buffer := []byte{}
if hasAndCondition {
if hasAndCondition || hasOffsetMatcher {
scanner.Buffer(buffer, int(defaultMaxReadSize))
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
defaultMaxReadSizeInt := int(defaultMaxReadSize)
if len(data) > defaultMaxReadSizeInt {
// the scanner buffer never grows past defaultMaxReadSize, so a full
// buffer must already yield a chunk or the scan fails as too long
if len(data) >= defaultMaxReadSizeInt {
return defaultMaxReadSizeInt, data[0:defaultMaxReadSizeInt], nil
}
if !atEOF {
@@ -308,8 +314,9 @@ func (request *Request) findMatchesWithReader(reader io.Reader, input *contextar
processedBytes := units.BytesSize(float64(currentBytes))
gologger.Verbose().Msgf("[%s] Processing file %s chunk %s/%s", request.options.TemplateID, filePath, processedBytes, totalBytesString)
dslMap := request.responseToDSLMap(lineContent, input.MetaInput.Input, filePath)
dslMap := request.responseToDSLMap(lineContent, input.MetaInput.Input, filePath, totalBytes)
maps.Copy(dslMap, previous)
dslMap[chunkStartKey] = bytesCount
// add vars to template context
request.options.AddTemplateVars(input.MetaInput, request.Type(), request.ID, dslMap)
// add template context variables to DSL map
@@ -374,10 +381,10 @@ func (request *Request) findMatchesWithReader(reader io.Reader, input *contextar
return fileMatches, opResult
}
func (request *Request) buildEvent(input, filePath string, fileMatches []FileMatch, operatorResult *operators.Result, previous output.InternalEvent) *output.InternalWrappedEvent {
func (request *Request) buildEvent(input, filePath string, fileSize int64, fileMatches []FileMatch, operatorResult *operators.Result, previous output.InternalEvent) *output.InternalWrappedEvent {
exprLines := make(map[string][]int)
exprBytes := make(map[string][]int)
internalEvent := request.responseToDSLMap("", input, filePath)
internalEvent := request.responseToDSLMap("", input, filePath, fileSize)
maps.Copy(internalEvent, previous)
for _, fileMatch := range fileMatches {
exprLines[fileMatch.Expr] = append(exprLines[fileMatch.Expr], fileMatch.Line)
+158 -1
View File
@@ -14,6 +14,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
"github.com/projectdiscovery/nuclei/v3/pkg/model"
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
"github.com/projectdiscovery/nuclei/v3/pkg/operators"
@@ -21,7 +22,6 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/operators/matchers"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
permissionutil "github.com/projectdiscovery/utils/permission"
)
@@ -212,3 +212,160 @@ func TestFileProtocolConcurrentExecution(t *testing.T) {
timesMutex.Lock()
defer timesMutex.Unlock()
}
func TestFileFilesizeAndOffset(t *testing.T) {
tempDir, err := os.MkdirTemp("", "nuclei-filesize-*")
require.NoError(t, err)
defer func() { _ = os.RemoveAll(tempDir) }()
content := []byte("MZPAYLOADDATA")
filePath := filepath.Join(tempDir, "sample.bin")
require.NoError(t, os.WriteFile(filePath, content, permissionutil.TempFilePermission))
options := testutils.DefaultOptions
testutils.Init(options)
templateID := "testing-file-filesize-offset"
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: templateID,
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
offset0 := 0
request := &Request{
ID: templateID,
MaxSize: "1Gb",
Extensions: []string{"all"},
Operators: operators.Operators{
MatchersCondition: "and",
Matchers: []*matchers.Matcher{
{
Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher},
Part: "raw",
Words: []string{"MZ"},
Offset: &offset0,
},
{
Type: matchers.MatcherTypeHolder{MatcherType: matchers.DSLMatcher},
DSL: []string{"filesize == 13"},
},
},
},
}
require.NoError(t, request.Compile(executerOpts))
var matched bool
ctxArgs := contextargs.NewWithInput(context.Background(), tempDir)
err = request.ExecuteWithResults(ctxArgs, nil, nil, func(event *output.InternalWrappedEvent) {
if event.OperatorsResult != nil && event.OperatorsResult.Matched {
matched = true
require.Equal(t, int64(len(content)), event.InternalEvent["filesize"])
}
})
require.NoError(t, err)
require.True(t, matched)
}
func TestFileOffsetUsesAbsolutePosition(t *testing.T) {
tempDir, err := os.MkdirTemp("", "nuclei-offset-*")
require.NoError(t, err)
defer func() { _ = os.RemoveAll(tempDir) }()
filePath := filepath.Join(tempDir, "sample.txt")
require.NoError(t, os.WriteFile(filePath, []byte("header\nMZ"), permissionutil.TempFilePermission))
options := testutils.DefaultOptions
testutils.Init(options)
templateID := "testing-file-absolute-offset"
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: templateID,
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
offset0 := 0
request := &Request{
ID: templateID,
MaxSize: "1Gb",
Extensions: []string{"txt"},
Operators: operators.Operators{
Matchers: []*matchers.Matcher{{
Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher},
Part: "raw",
Words: []string{"MZ"},
Offset: &offset0,
}},
},
}
require.NoError(t, request.Compile(executerOpts))
var matched bool
ctxArgs := contextargs.NewWithInput(context.Background(), tempDir)
err = request.ExecuteWithResults(ctxArgs, nil, nil, func(event *output.InternalWrappedEvent) {
matched = event.OperatorsResult != nil && event.OperatorsResult.Matched
})
require.NoError(t, err)
require.False(t, matched)
}
func TestFileOffsetAbsoluteAcrossChunks(t *testing.T) {
// shrink the chunk size so the file is read in several chunks
previousMaxReadSize := defaultMaxReadSize
defaultMaxReadSize = 5
defer func() { defaultMaxReadSize = previousMaxReadSize }()
tempDir, err := os.MkdirTemp("", "nuclei-offset-chunks-*")
require.NoError(t, err)
defer func() { _ = os.RemoveAll(tempDir) }()
// chunks are "01234", "56789" and "MZabc"
filePath := filepath.Join(tempDir, "sample.bin")
require.NoError(t, os.WriteFile(filePath, []byte("0123456789MZabc"), permissionutil.TempFilePermission))
options := testutils.DefaultOptions
testutils.Init(options)
run := func(t *testing.T, matcher *matchers.Matcher) bool {
templateID := "testing-file-offset-chunks"
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
ID: templateID,
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
})
request := &Request{
ID: templateID,
MaxSize: "1Kb",
Extensions: []string{"all"},
Operators: operators.Operators{Matchers: []*matchers.Matcher{matcher}},
}
require.NoError(t, request.Compile(executerOpts))
var matched bool
ctxArgs := contextargs.NewWithInput(context.Background(), tempDir)
err := request.ExecuteWithResults(ctxArgs, nil, nil, func(event *output.InternalWrappedEvent) {
if event.OperatorsResult != nil && event.OperatorsResult.Matched {
matched = true
}
})
require.NoError(t, err)
return matched
}
offset := func(v int) *int { return &v }
tests := []struct {
name string
matcher *matchers.Matcher
want bool
}{
{"word at absolute offset", &matchers.Matcher{Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher}, Words: []string{"MZ"}, Offset: offset(10)}, true},
{"word at chunk relative offset", &matchers.Matcher{Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher}, Words: []string{"MZ"}, Offset: offset(0)}, false},
{"word inside second chunk", &matchers.Matcher{Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher}, Words: []string{"78"}, Offset: offset(7)}, true},
{"case insensitive word", &matchers.Matcher{Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher}, Words: []string{"mz"}, CaseInsensitive: true, Offset: offset(10)}, true},
{"binary at absolute offset", &matchers.Matcher{Type: matchers.MatcherTypeHolder{MatcherType: matchers.BinaryMatcher}, Binary: []string{"4d5a"}, Offset: offset(10)}, true},
{"regex at absolute offset", &matchers.Matcher{Type: matchers.MatcherTypeHolder{MatcherType: matchers.RegexMatcher}, Regex: []string{"M[A-Z]"}, Offset: offset(10)}, true},
{"regex at chunk relative offset", &matchers.Matcher{Type: matchers.MatcherTypeHolder{MatcherType: matchers.RegexMatcher}, Regex: []string{"M[A-Z]"}, Offset: offset(0)}, false},
{"regex inside last chunk", &matchers.Matcher{Type: matchers.MatcherTypeHolder{MatcherType: matchers.RegexMatcher}, Regex: []string{"Z[a-z]+"}, Offset: offset(11)}, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, run(t, tc.matcher))
})
}
}