From 5e4bfa946888f8d43ac8fec070c3a71771a04412 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:40:16 -0700 Subject: [PATCH] feat(modules): add a range matcher for response size or status the existing size matcher only checks exact-match against a list of values. add a range matcher type with inclusive min/max bounds over either the response/banner byte length (source: size, the default) or the http status code (source: status). an unbounded or inverted range, or an unknown source, is rejected at load rather than silently never firing at match time. --- internal/modules/executor.go | 21 ++++ internal/modules/favicon.go | 31 +++-- internal/modules/matchers_range_test.go | 145 ++++++++++++++++++++++++ internal/modules/module.go | 10 +- 4 files changed, 197 insertions(+), 10 deletions(-) create mode 100644 internal/modules/matchers_range_test.go diff --git a/internal/modules/executor.go b/internal/modules/executor.go index f969581a..ea30d0ca 100644 --- a/internal/modules/executor.go +++ b/internal/modules/executor.go @@ -388,11 +388,32 @@ func checkMatcher(m *Matcher, resp *http.Response, body string) bool { } return false + case "range": + switch strings.ToLower(m.Source) { + case "status": + return inRange(resp.StatusCode, m.Min, m.Max) + case "size", "": + return inRange(len(body), m.Min, m.Max) + default: + return false + } + default: return false } } +// inRange reports whether v is within the inclusive bounds; a nil bound is open. +func inRange(v int, lo, hi *int) bool { + if lo != nil && v < *lo { + return false + } + if hi != nil && v > *hi { + return false + } + return true +} + // getPart extracts the relevant part of the response. func getPart(part string, resp *http.Response, body string) string { switch part { diff --git a/internal/modules/favicon.go b/internal/modules/favicon.go index efdb973a..ff816d41 100644 --- a/internal/modules/favicon.go +++ b/internal/modules/favicon.go @@ -15,6 +15,7 @@ package modules import ( "fmt" "math" + "strings" "github.com/vmfunc/sif/internal/fingerprint" ) @@ -63,18 +64,30 @@ func faviconEvidence(matchers []Matcher, body string) (string, bool) { } // validateMatchers fails favicon matchers that would silently never fire (no -// hash, or one out of 32-bit range) at load rather than at match time. +// hash, or one out of 32-bit range) and malformed range matchers at load +// rather than at match time. func validateMatchers(matchers []Matcher) error { for i := range matchers { - if matchers[i].Type != "favicon" { - continue - } - if len(matchers[i].Hash) == 0 { - return fmt.Errorf("favicon matcher requires at least one hash") + if matchers[i].Type == "favicon" { + if len(matchers[i].Hash) == 0 { + return fmt.Errorf("favicon matcher requires at least one hash") + } + for _, h := range matchers[i].Hash { + if _, ok := normalizeFaviconHash(h); !ok { + return fmt.Errorf("favicon hash %d out of range (use a signed int32 or unsigned uint32 value)", h) + } + } } - for _, h := range matchers[i].Hash { - if _, ok := normalizeFaviconHash(h); !ok { - return fmt.Errorf("favicon hash %d out of range (use a signed int32 or unsigned uint32 value)", h) + + if matchers[i].Type == "range" { + if matchers[i].Min == nil && matchers[i].Max == nil { + return fmt.Errorf("range matcher requires min or max") + } + if s := strings.ToLower(matchers[i].Source); s != "" && s != "size" && s != "status" { + return fmt.Errorf("range matcher source %q not supported (use size or status)", matchers[i].Source) + } + if matchers[i].Min != nil && matchers[i].Max != nil && *matchers[i].Min > *matchers[i].Max { + return fmt.Errorf("range matcher min %d greater than max %d", *matchers[i].Min, *matchers[i].Max) } } } diff --git a/internal/modules/matchers_range_test.go b/internal/modules/matchers_range_test.go new file mode 100644 index 00000000..f270794f --- /dev/null +++ b/internal/modules/matchers_range_test.go @@ -0,0 +1,145 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package modules + +import "testing" + +func TestInRange(t *testing.T) { + intp := func(n int) *int { return &n } + + tests := []struct { + name string + v int + min *int + max *int + want bool + }{ + {name: "within both bounds", v: 50, min: intp(10), max: intp(100), want: true}, + {name: "below min", v: 5, min: intp(10), max: intp(100), want: false}, + {name: "above max", v: 101, min: intp(10), max: intp(100), want: false}, + {name: "at min inclusive", v: 10, min: intp(10), max: intp(100), want: true}, + {name: "at max inclusive", v: 100, min: intp(10), max: intp(100), want: true}, + {name: "min only, above", v: 1000, min: intp(10), max: nil, want: true}, + {name: "min only, below", v: 5, min: intp(10), max: nil, want: false}, + {name: "max only, below", v: 5, min: nil, max: intp(100), want: true}, + {name: "max only, above", v: 1000, min: nil, max: intp(100), want: false}, + {name: "both nil is vacuously true", v: 12345, min: nil, max: nil, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := inRange(tt.v, tt.min, tt.max); got != tt.want { + t.Errorf("inRange(%d) = %v, want %v", tt.v, got, tt.want) + } + }) + } +} + +func TestCheckMatcherRange(t *testing.T) { + intp := func(n int) *int { return &n } + + t.Run("status source in range", func(t *testing.T) { + resp := fakeResponse(t, 503, nil) + m := &Matcher{Type: "range", Source: "status", Min: intp(500), Max: intp(599)} + if !checkMatcher(m, resp, "") { + t.Error("expected 503 to be within 500-599") + } + }) + + t.Run("status source out of range", func(t *testing.T) { + resp := fakeResponse(t, 200, nil) + m := &Matcher{Type: "range", Source: "status", Min: intp(500), Max: intp(599)} + if checkMatcher(m, resp, "") { + t.Error("expected 200 to be outside 500-599") + } + }) + + t.Run("size source default", func(t *testing.T) { + resp := fakeResponse(t, 200, nil) + m := &Matcher{Type: "range", Min: intp(5), Max: intp(20)} + if !checkMatcher(m, resp, "twelve chars") { + t.Error("expected body length within bounds to match") + } + }) + + t.Run("size source explicit", func(t *testing.T) { + resp := fakeResponse(t, 200, nil) + m := &Matcher{Type: "range", Source: "size", Min: intp(1000)} + if checkMatcher(m, resp, "short") { + t.Error("expected short body to miss a high min bound") + } + }) +} + +// TestParseYAMLModuleMatcherRangeFields confirms yaml.v3 unmarshals the range +// matcher's scalars into *int (allocating on presence, nil on absence). +func TestParseYAMLModuleMatcherRangeFields(t *testing.T) { + const doc = `id: new-fields +type: http +info: + severity: info +http: + method: GET + paths: ["{{BaseURL}}/"] + matchers: + - type: range + source: status + min: 200 + max: 299 +` + dir := t.TempDir() + path := writeModule(t, dir, "new-fields.yaml", doc) + def, err := ParseYAMLModule(path) + if err != nil { + t.Fatalf("ParseYAMLModule: %v", err) + } + if len(def.HTTP.Matchers) != 1 { + t.Fatalf("got %d matchers, want 1", len(def.HTTP.Matchers)) + } + + rng := def.HTTP.Matchers[0] + if rng.Source != "status" { + t.Errorf("Source = %q, want status", rng.Source) + } + if rng.Min == nil || *rng.Min != 200 { + t.Fatalf("Min = %v, want *200", rng.Min) + } + if rng.Max == nil || *rng.Max != 299 { + t.Fatalf("Max = %v, want *299", rng.Max) + } +} + +func TestParseYAMLModuleMatcherRangeValidation(t *testing.T) { + dir := t.TempDir() + write := func(name, body string) string { return writeModule(t, dir, name, body) } + + rangeNoBounds := write("range-no-bounds.yaml", "id: rnb\ntype: http\nhttp:\n paths: [\"/\"]\n matchers:\n - type: range\n") + if _, err := ParseYAMLModule(rangeNoBounds); err == nil { + t.Fatal("range matcher with no bounds accepted") + } + + rangeMinMax := write("range-min-max.yaml", "id: rmm\ntype: http\nhttp:\n paths: [\"/\"]\n matchers:\n - type: range\n min: 100\n max: 1\n") + if _, err := ParseYAMLModule(rangeMinMax); err == nil { + t.Fatal("range matcher with min>max accepted") + } + + rangeBadSource := write("range-bad-source.yaml", "id: rbs\ntype: http\nhttp:\n paths: [\"/\"]\n matchers:\n - type: range\n source: bogus\n min: 1\n") + if _, err := ParseYAMLModule(rangeBadSource); err == nil { + t.Fatal("range matcher with bad source accepted") + } + + rangeOK := write("range-ok.yaml", "id: rok\ntype: http\nhttp:\n paths: [\"/\"]\n matchers:\n - type: range\n source: size\n min: 0\n max: 1000\n") + if _, err := ParseYAMLModule(rangeOK); err != nil { + t.Fatalf("valid range matcher rejected: %v", err) + } +} diff --git a/internal/modules/module.go b/internal/modules/module.go index 1a45e5c4..db2e8092 100644 --- a/internal/modules/module.go +++ b/internal/modules/module.go @@ -86,7 +86,7 @@ type Finding struct { // Matcher defines matching logic for module responses. // Matchers are used to determine if a response indicates a vulnerability. type Matcher struct { - Type string `yaml:"type"` // regex, status, word, favicon + Type string `yaml:"type"` // regex, status, word, favicon, size, range Part string `yaml:"part"` // body, header, all Regex []string `yaml:"regex,omitempty"` Words []string `yaml:"words,omitempty"` @@ -95,6 +95,14 @@ type Matcher struct { Hash []int64 `yaml:"hash,omitempty"` // favicon: shodan mmh3 hashes (signed or unsigned) Condition string `yaml:"condition"` // and, or Negative bool `yaml:"negative"` + + // Source selects the numeric value a range matcher tests: size (default, + // response/banner byte length) or status (http status code). range only. + Source string `yaml:"source,omitempty"` + // Min and Max are the inclusive bounds of a range matcher. A nil bound is + // unbounded on that side; at least one must be set. range only. + Min *int `yaml:"min,omitempty"` + Max *int `yaml:"max,omitempty"` } // Extractor defines data extraction from responses.