Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions internal/modules/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
31 changes: 22 additions & 9 deletions internal/modules/favicon.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ package modules
import (
"fmt"
"math"
"strings"

"github.com/vmfunc/sif/internal/fingerprint"
)
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
145 changes: 145 additions & 0 deletions internal/modules/matchers_range_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
10 changes: 9 additions & 1 deletion internal/modules/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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.
Expand Down
Loading