mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 14:37:01 -07:00
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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user