/* ·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· : : : █▀ █ █▀▀ · 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: "