feat(modules): add a range matcher for response size or status (#362)

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.

Co-authored-by: vmfunc <vmfunc.lc@gmail.com>
This commit is contained in:
Tigah
2026-07-22 22:40:58 +00:00
committed by GitHub
co-authored by vmfunc
parent 3e0029755e
commit c195e88453
4 changed files with 197 additions and 10 deletions
+21
View File
@@ -400,11 +400,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 {
+22 -9
View File
@@ -15,6 +15,7 @@ package modules
import (
"fmt"
"math"
"strings"
"github.com/vmfunc/sif/internal/fingerprint"
)
@@ -67,18 +68,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 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)
}
}
}
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)
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)
}
}
}
+145
View File
@@ -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)
}
}
+9 -1
View File
@@ -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"`
// CaseInsensitive folds word matching to lower-case when set (word matcher only).
CaseInsensitive bool `yaml:"case-insensitive,omitempty"`
}