mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-12 19:11:25 -07:00
320fc3d4e7
the yaml module engine (the user-facing extensibility surface) had 0% test coverage. add table-driven tests for the matcher types (status/word/regex + and/or + negative), checkWords/checkRegex (incl invalid-pattern fail-closed under AND, skip under OR), runExtractors (regex capture groups, group-index bounds, part selection), substituteVariables and generateHTTPRequests (path x payload expansion), and ParseYAMLModule on valid + malformed yaml. drive ExecuteHTTPModule end-to-end against an httptest server through the shared httpx client so matcher hits and extractor captures are exercised for real. coverage 0% -> 93.7%. also: ExecuteDNSModule/ExecuteTCPModule were stubs returning an empty result with nil error, so a type:dns/type:tcp module silently reported "0 findings" - indistinguishable from a real clean scan. make them return ErrUnsupportedModuleType (sentinel, wrapped with the module id) so the existing caller logs a clear failure instead. a test pins the new behavior. bodyclose is excluded for test files in .golangci.yml: the synthetic *http.Response fixtures carry no socket, mirroring the existing _test.go slack for errcheck/noctx/gosec.
466 lines
15 KiB
Go
466 lines
15 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
|
: ▄█ █ █▀ · BSD 3-Clause License :
|
|
: :
|
|
: (c) 2022-2026 vmfunc, xyzeva, :
|
|
: lunchcat alumni & contributors :
|
|
: :
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
*/
|
|
|
|
package modules
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// fakeResponse builds a minimal *http.Response for matcher/extractor tests.
|
|
// it carries no real socket (Body is http.NoBody), so there is nothing to
|
|
// close; bodyclose is excluded for test files in .golangci.yml. header drives
|
|
// the header/all parts without a live server; matchers read the body string
|
|
// argument, not resp.Body.
|
|
func fakeResponse(t *testing.T, status int, header http.Header) *http.Response {
|
|
t.Helper()
|
|
if header == nil {
|
|
header = http.Header{}
|
|
}
|
|
return &http.Response{StatusCode: status, Header: header, Body: http.NoBody}
|
|
}
|
|
|
|
func TestCheckMatcherStatus(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
want []int
|
|
expect bool
|
|
}{
|
|
{name: "single match", status: 200, want: []int{200}, expect: true},
|
|
{name: "one of many", status: 404, want: []int{200, 301, 404}, expect: true},
|
|
{name: "no match", status: 500, want: []int{200, 404}, expect: false},
|
|
{name: "empty status list", status: 200, want: nil, expect: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
m := &Matcher{Type: "status", Status: tt.want}
|
|
resp := fakeResponse(t, tt.status, nil)
|
|
if got := checkMatcher(m, resp, ""); got != tt.expect {
|
|
t.Errorf("checkMatcher status = %v, want %v", got, tt.expect)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckMatcherWord(t *testing.T) {
|
|
const body = "welcome admin dashboard"
|
|
|
|
tests := []struct {
|
|
name string
|
|
words []string
|
|
condition string
|
|
expect bool
|
|
}{
|
|
{name: "and all present", words: []string{"admin", "dashboard"}, condition: "and", expect: true},
|
|
{name: "and one missing", words: []string{"admin", "missing"}, condition: "and", expect: false},
|
|
{name: "default is and", words: []string{"admin", "missing"}, condition: "", expect: false},
|
|
{name: "or one present", words: []string{"missing", "admin"}, condition: "or", expect: true},
|
|
{name: "or none present", words: []string{"missing", "absent"}, condition: "or", expect: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
m := &Matcher{Type: "word", Part: "body", Words: tt.words, Condition: tt.condition}
|
|
resp := fakeResponse(t, 200, nil)
|
|
if got := checkMatcher(m, resp, body); got != tt.expect {
|
|
t.Errorf("checkMatcher word = %v, want %v", got, tt.expect)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckMatcherRegex(t *testing.T) {
|
|
const body = "version 1.2.3 build 99"
|
|
|
|
tests := []struct {
|
|
name string
|
|
patterns []string
|
|
condition string
|
|
expect bool
|
|
}{
|
|
{name: "and all match", patterns: []string{`version \d`, `build \d+`}, condition: "and", expect: true},
|
|
{name: "and one fails", patterns: []string{`version \d`, `nope\d`}, condition: "and", expect: false},
|
|
{name: "or one matches", patterns: []string{`nope`, `build \d+`}, condition: "or", expect: true},
|
|
{name: "or none match", patterns: []string{`nope`, `zilch`}, condition: "or", expect: false},
|
|
// an invalid pattern under AND must fail closed, not panic.
|
|
{name: "and invalid pattern fails closed", patterns: []string{`version \d`, `(`}, condition: "and", expect: false},
|
|
// under OR an invalid pattern is skipped, a later valid one can still hit.
|
|
{name: "or invalid pattern skipped", patterns: []string{`(`, `build \d+`}, condition: "or", expect: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
m := &Matcher{Type: "regex", Part: "body", Regex: tt.patterns, Condition: tt.condition}
|
|
resp := fakeResponse(t, 200, nil)
|
|
if got := checkMatcher(m, resp, body); got != tt.expect {
|
|
t.Errorf("checkMatcher regex = %v, want %v", got, tt.expect)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckMatcherHeaderPart(t *testing.T) {
|
|
header := http.Header{"X-Powered-By": []string{"PHP/8.1"}}
|
|
resp := fakeResponse(t, 200, header)
|
|
|
|
m := &Matcher{Type: "word", Part: "header", Words: []string{"PHP/8.1"}}
|
|
if !checkMatcher(m, resp, "body-content") {
|
|
t.Error("expected header-part word matcher to hit on header value")
|
|
}
|
|
|
|
// the same word lives only in the header, so a body-part matcher must miss.
|
|
mBody := &Matcher{Type: "word", Part: "body", Words: []string{"PHP/8.1"}}
|
|
if checkMatcher(mBody, resp, "body-content") {
|
|
t.Error("body-part matcher should not see header-only value")
|
|
}
|
|
}
|
|
|
|
func TestCheckMatcherUnknownType(t *testing.T) {
|
|
m := &Matcher{Type: "size", Part: "body"}
|
|
resp := fakeResponse(t, 200, nil)
|
|
if checkMatcher(m, resp, "anything") {
|
|
t.Error("unknown matcher type should not match")
|
|
}
|
|
}
|
|
|
|
func TestCheckMatchers(t *testing.T) {
|
|
resp := fakeResponse(t, 200, http.Header{"Server": []string{"nginx"}})
|
|
const body = "secret token here"
|
|
|
|
tests := []struct {
|
|
name string
|
|
matchers []Matcher
|
|
expect bool
|
|
}{
|
|
{
|
|
name: "empty matchers never match",
|
|
matchers: nil,
|
|
expect: false,
|
|
},
|
|
{
|
|
name: "all matchers pass (AND across matchers)",
|
|
matchers: []Matcher{
|
|
{Type: "status", Status: []int{200}},
|
|
{Type: "word", Part: "body", Words: []string{"secret"}},
|
|
},
|
|
expect: true,
|
|
},
|
|
{
|
|
name: "one matcher fails breaks AND",
|
|
matchers: []Matcher{
|
|
{Type: "status", Status: []int{200}},
|
|
{Type: "word", Part: "body", Words: []string{"absent"}},
|
|
},
|
|
expect: false,
|
|
},
|
|
{
|
|
name: "negative inverts a non-match into a pass",
|
|
matchers: []Matcher{
|
|
{Type: "word", Part: "body", Words: []string{"absent"}, Negative: true},
|
|
},
|
|
expect: true,
|
|
},
|
|
{
|
|
name: "negative inverts a match into a fail",
|
|
matchers: []Matcher{
|
|
{Type: "word", Part: "body", Words: []string{"secret"}, Negative: true},
|
|
},
|
|
expect: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := checkMatchers(tt.matchers, resp, body); got != tt.expect {
|
|
t.Errorf("checkMatchers = %v, want %v", got, tt.expect)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckWords(t *testing.T) {
|
|
const content = "alpha beta gamma"
|
|
|
|
tests := []struct {
|
|
name string
|
|
words []string
|
|
condition string
|
|
expect bool
|
|
}{
|
|
{name: "and all present", words: []string{"alpha", "gamma"}, condition: "and", expect: true},
|
|
{name: "and missing", words: []string{"alpha", "delta"}, condition: "and", expect: false},
|
|
{name: "or present", words: []string{"delta", "beta"}, condition: "or", expect: true},
|
|
{name: "or absent", words: []string{"delta", "epsilon"}, condition: "or", expect: false},
|
|
{name: "empty under and matches vacuously", words: nil, condition: "and", expect: true},
|
|
{name: "empty under or matches nothing", words: nil, condition: "or", expect: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := checkWords(content, tt.words, tt.condition); got != tt.expect {
|
|
t.Errorf("checkWords = %v, want %v", got, tt.expect)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckRegex(t *testing.T) {
|
|
const content = "id=42 name=root"
|
|
|
|
tests := []struct {
|
|
name string
|
|
patterns []string
|
|
condition string
|
|
expect bool
|
|
}{
|
|
{name: "and all match", patterns: []string{`id=\d+`, `name=\w+`}, condition: "and", expect: true},
|
|
{name: "and one fails", patterns: []string{`id=\d+`, `zzz`}, condition: "and", expect: false},
|
|
{name: "or first matches", patterns: []string{`id=\d+`, `zzz`}, condition: "or", expect: true},
|
|
{name: "or none match", patterns: []string{`xxx`, `zzz`}, condition: "or", expect: false},
|
|
{name: "and bad regex fails closed", patterns: []string{`(`}, condition: "and", expect: false},
|
|
{name: "or bad regex skipped then match", patterns: []string{`(`, `name=\w+`}, condition: "or", expect: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := checkRegex(content, tt.patterns, tt.condition); got != tt.expect {
|
|
t.Errorf("checkRegex = %v, want %v", got, tt.expect)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetPart(t *testing.T) {
|
|
header := http.Header{"Server": []string{"nginx"}}
|
|
resp := fakeResponse(t, 200, header)
|
|
const body = "page body"
|
|
|
|
if got := getPart("body", resp, body); got != body {
|
|
t.Errorf("getPart body = %q, want %q", got, body)
|
|
}
|
|
|
|
headerPart := getPart("header", resp, body)
|
|
if !strings.Contains(headerPart, "Server") || !strings.Contains(headerPart, "nginx") {
|
|
t.Errorf("getPart header = %q, want it to include the header", headerPart)
|
|
}
|
|
if strings.Contains(headerPart, body) {
|
|
t.Errorf("getPart header should not include body, got %q", headerPart)
|
|
}
|
|
|
|
all := getPart("all", resp, body)
|
|
if !strings.Contains(all, "nginx") || !strings.Contains(all, body) {
|
|
t.Errorf("getPart all = %q, want both header and body", all)
|
|
}
|
|
|
|
// an unrecognised part falls back to the body.
|
|
if got := getPart("weird", resp, body); got != body {
|
|
t.Errorf("getPart fallback = %q, want body %q", got, body)
|
|
}
|
|
|
|
// empty part behaves like "all".
|
|
if got := getPart("", resp, body); !strings.Contains(got, "nginx") || !strings.Contains(got, body) {
|
|
t.Errorf("getPart empty = %q, want both header and body", got)
|
|
}
|
|
}
|
|
|
|
func TestRunExtractors(t *testing.T) {
|
|
resp := fakeResponse(t, 200, http.Header{"X-Token": []string{"abc123"}})
|
|
const body = `{"session":"sess-7788","role":"admin"}`
|
|
|
|
tests := []struct {
|
|
name string
|
|
extractors []Extractor
|
|
wantKey string
|
|
wantVal string
|
|
wantNil bool
|
|
}{
|
|
{
|
|
name: "no extractors yields nil",
|
|
extractors: nil,
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "regex capture group on body",
|
|
extractors: []Extractor{
|
|
{Type: "regex", Name: "session", Part: "body", Regex: []string{`"session":"([^"]+)"`}, Group: 1},
|
|
},
|
|
wantKey: "session",
|
|
wantVal: "sess-7788",
|
|
},
|
|
{
|
|
name: "group zero is the whole match",
|
|
extractors: []Extractor{
|
|
{Type: "regex", Name: "role", Part: "body", Regex: []string{`role":"admin`}, Group: 0},
|
|
},
|
|
wantKey: "role",
|
|
wantVal: `role":"admin`,
|
|
},
|
|
{
|
|
name: "extract from header part",
|
|
extractors: []Extractor{
|
|
{Type: "regex", Name: "token", Part: "header", Regex: []string{`X-Token: (\S+)`}, Group: 1},
|
|
},
|
|
wantKey: "token",
|
|
wantVal: "abc123",
|
|
},
|
|
{
|
|
name: "first matching pattern wins",
|
|
extractors: []Extractor{
|
|
{Type: "regex", Name: "session", Part: "body", Regex: []string{`nomatch(\d+)`, `"session":"([^"]+)"`}, Group: 1},
|
|
},
|
|
wantKey: "session",
|
|
wantVal: "sess-7788",
|
|
},
|
|
{
|
|
name: "group index out of range is skipped",
|
|
extractors: []Extractor{
|
|
{Type: "regex", Name: "session", Part: "body", Regex: []string{`"session":"([^"]+)"`}, Group: 5},
|
|
},
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "invalid pattern is skipped, no capture",
|
|
extractors: []Extractor{
|
|
{Type: "regex", Name: "session", Part: "body", Regex: []string{`(`}, Group: 1},
|
|
},
|
|
wantNil: true,
|
|
},
|
|
{
|
|
name: "non-regex extractor type is ignored",
|
|
extractors: []Extractor{
|
|
{Type: "kval", Name: "session", Part: "body", Regex: []string{`"session":"([^"]+)"`}, Group: 1},
|
|
},
|
|
wantNil: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := runExtractors(tt.extractors, resp, body)
|
|
if tt.wantNil {
|
|
if len(got) != 0 {
|
|
t.Errorf("runExtractors = %v, want empty", got)
|
|
}
|
|
return
|
|
}
|
|
if got[tt.wantKey] != tt.wantVal {
|
|
t.Errorf("runExtractors[%q] = %q, want %q", tt.wantKey, got[tt.wantKey], tt.wantVal)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSubstituteVariables(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
template string
|
|
baseURL string
|
|
payload string
|
|
want string
|
|
}{
|
|
{
|
|
name: "baseurl both cases",
|
|
template: "{{BaseURL}}/x and {{baseurl}}/y",
|
|
baseURL: "http://h",
|
|
want: "http://h/x and http://h/y",
|
|
},
|
|
{
|
|
name: "payload both cases",
|
|
template: "q={{payload}}&r={{Payload}}",
|
|
payload: "<script>",
|
|
want: "q=<script>&r=<script>",
|
|
},
|
|
{
|
|
name: "combined base and payload",
|
|
template: "{{BaseURL}}/search?q={{payload}}",
|
|
baseURL: "http://h",
|
|
payload: "x",
|
|
want: "http://h/search?q=x",
|
|
},
|
|
{
|
|
name: "no placeholders untouched",
|
|
template: "/static/path",
|
|
baseURL: "http://h",
|
|
want: "/static/path",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := substituteVariables(tt.template, tt.baseURL, tt.payload); got != tt.want {
|
|
t.Errorf("substituteVariables = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGenerateHTTPRequests(t *testing.T) {
|
|
t.Run("paths without payloads", func(t *testing.T) {
|
|
cfg := &HTTPConfig{
|
|
Paths: []string{"{{BaseURL}}/a", "{{BaseURL}}/b"},
|
|
}
|
|
// trailing slash on the target must be trimmed before substitution.
|
|
got := generateHTTPRequests("http://h/", cfg)
|
|
if len(got) != 2 {
|
|
t.Fatalf("got %d requests, want 2", len(got))
|
|
}
|
|
if got[0].Method != "GET" {
|
|
t.Errorf("default method = %q, want GET", got[0].Method)
|
|
}
|
|
if got[0].URL != "http://h/a" || got[1].URL != "http://h/b" {
|
|
t.Errorf("urls = %q,%q", got[0].URL, got[1].URL)
|
|
}
|
|
})
|
|
|
|
t.Run("payload expansion is path x payload", func(t *testing.T) {
|
|
cfg := &HTTPConfig{
|
|
Method: "POST",
|
|
Paths: []string{"{{BaseURL}}/q?x={{payload}}"},
|
|
Payloads: []string{"1", "2", "3"},
|
|
Body: "data={{payload}}",
|
|
}
|
|
got := generateHTTPRequests("http://h", cfg)
|
|
if len(got) != 3 {
|
|
t.Fatalf("got %d requests, want 3", len(got))
|
|
}
|
|
for i, want := range []string{"1", "2", "3"} {
|
|
if got[i].Payload != want {
|
|
t.Errorf("req %d payload = %q, want %q", i, got[i].Payload, want)
|
|
}
|
|
if got[i].URL != "http://h/q?x="+want {
|
|
t.Errorf("req %d url = %q", i, got[i].URL)
|
|
}
|
|
if got[i].Body != "data="+want {
|
|
t.Errorf("req %d body = %q", i, got[i].Body)
|
|
}
|
|
if got[i].Method != "POST" {
|
|
t.Errorf("req %d method = %q, want POST", i, got[i].Method)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("multiple paths times multiple payloads", func(t *testing.T) {
|
|
cfg := &HTTPConfig{
|
|
Paths: []string{"{{BaseURL}}/a", "{{BaseURL}}/b"},
|
|
Payloads: []string{"x", "y"},
|
|
}
|
|
got := generateHTTPRequests("http://h", cfg)
|
|
if len(got) != 4 {
|
|
t.Fatalf("got %d requests, want 4 (2 paths x 2 payloads)", len(got))
|
|
}
|
|
})
|
|
}
|