From 320fc3d4e7ad74b5cd7fd09458721ad9aeb8668d Mon Sep 17 00:00:00 2001 From: vmfunc Date: Wed, 10 Jun 2026 14:45:21 -0700 Subject: [PATCH] test(modules): cover matchers, extractors, loader and executor 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. --- .golangci.yml | 1 + internal/modules/executor.go | 32 +- internal/modules/executor_test.go | 270 +++++++++++++++++ internal/modules/loader_test.go | 269 +++++++++++++++++ internal/modules/matchers_test.go | 465 ++++++++++++++++++++++++++++++ 5 files changed, 1021 insertions(+), 16 deletions(-) create mode 100644 internal/modules/executor_test.go create mode 100644 internal/modules/loader_test.go create mode 100644 internal/modules/matchers_test.go diff --git a/.golangci.yml b/.golangci.yml index 08583ea..f256c34 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -89,6 +89,7 @@ linters: - errcheck - noctx - gosec # fake credentials in secret-scanner fixtures are not real keys + - bodyclose # synthetic *http.Response fixtures carry no socket to close issues: max-issues-per-linter: 50 diff --git a/internal/modules/executor.go b/internal/modules/executor.go index eafba93..e08aeeb 100644 --- a/internal/modules/executor.go +++ b/internal/modules/executor.go @@ -14,6 +14,7 @@ package modules import ( "context" + "errors" "fmt" "io" "net/http" @@ -26,6 +27,11 @@ import ( // MaxBodySize limits response body to prevent memory exhaustion. const MaxBodySize = 5 * 1024 * 1024 +// ErrUnsupportedModuleType signals an executor for a module type that is not +// yet implemented. Returning it (rather than an empty result) keeps callers +// from mistaking "not implemented" for "scanned, found nothing". +var ErrUnsupportedModuleType = errors.New("unsupported module type") + // httpRequest represents a generated HTTP request. type httpRequest struct { Method string @@ -379,22 +385,16 @@ func truncateEvidence(s string) string { return s } -// ExecuteDNSModule runs a DNS-based module (stub for now). -func ExecuteDNSModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) { - // TODO: Implement DNS module execution - return &Result{ - ModuleID: def.ID, - Target: target, - Findings: []Finding{}, - }, nil +// ExecuteDNSModule runs a DNS-based module (not yet implemented). +// returns ErrUnsupportedModuleType so the caller logs a clear failure rather +// than reporting an empty (but successful-looking) result. +func ExecuteDNSModule(_ context.Context, _ string, def *YAMLModule, _ Options) (*Result, error) { + return nil, fmt.Errorf("dns module %q: %w", def.ID, ErrUnsupportedModuleType) } -// ExecuteTCPModule runs a TCP-based module (stub for now). -func ExecuteTCPModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) { - // TODO: Implement TCP module execution - return &Result{ - ModuleID: def.ID, - Target: target, - Findings: []Finding{}, - }, nil +// ExecuteTCPModule runs a TCP-based module (not yet implemented). +// returns ErrUnsupportedModuleType so the caller logs a clear failure rather +// than reporting an empty (but successful-looking) result. +func ExecuteTCPModule(_ context.Context, _ string, def *YAMLModule, _ Options) (*Result, error) { + return nil, fmt.Errorf("tcp module %q: %w", def.ID, ErrUnsupportedModuleType) } diff --git a/internal/modules/executor_test.go b/internal/modules/executor_test.go new file mode 100644 index 0000000..e1fcd56 --- /dev/null +++ b/internal/modules/executor_test.go @@ -0,0 +1,270 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package modules + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/dropalldatabases/sif/internal/httpx" +) + +const testTimeout = 5 * time.Second + +// TestExecuteHTTPModuleMatchAndExtract drives the full executor against a live +// httptest server: a request hits a path, a matcher fires, an extractor captures. +func TestExecuteHTTPModuleMatchAndExtract(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/admin" { + w.Header().Set("X-App", "demo") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`flag{found-it} session=sess-4242`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + def := &YAMLModule{ + ID: "test-http-hit", + Type: TypeHTTP, + Info: YAMLModuleInfo{Severity: "high"}, + HTTP: &HTTPConfig{ + Method: "GET", + Paths: []string{"{{BaseURL}}/admin", "{{BaseURL}}/missing"}, + Matchers: []Matcher{ + {Type: "status", Status: []int{200}}, + {Type: "word", Part: "body", Words: []string{"flag{found-it}"}}, + }, + Extractors: []Extractor{ + {Type: "regex", Name: "session", Part: "body", Regex: []string{`session=(\S+)`}, Group: 1}, + }, + }, + } + + // route through the shared httpx client so proxy/-H/-rate-limit would apply. + opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)} + + result, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts) + if err != nil { + t.Fatalf("ExecuteHTTPModule: %v", err) + } + + // only /admin satisfies status+word, /missing returns 404. + if len(result.Findings) != 1 { + t.Fatalf("got %d findings, want 1", len(result.Findings)) + } + f := result.Findings[0] + if f.Severity != "high" { + t.Errorf("severity = %q, want high (carried from Info)", f.Severity) + } + if f.Extracted["session"] != "sess-4242" { + t.Errorf("extracted session = %q, want sess-4242", f.Extracted["session"]) + } + if f.URL != srv.URL+"/admin" { + t.Errorf("finding url = %q, want %q", f.URL, srv.URL+"/admin") + } +} + +// TestExecuteHTTPModuleNoMatch confirms a module that matches nothing reports +// zero findings without erroring. +func TestExecuteHTTPModuleNoMatch(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("nothing interesting")) + })) + defer srv.Close() + + def := &YAMLModule{ + ID: "test-http-miss", + Type: TypeHTTP, + HTTP: &HTTPConfig{ + Paths: []string{"{{BaseURL}}/"}, + Matchers: []Matcher{ + {Type: "word", Part: "body", Words: []string{"never-present"}}, + }, + }, + } + + result, err := ExecuteHTTPModule(context.Background(), srv.URL, def, Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}) + if err != nil { + t.Fatalf("ExecuteHTTPModule: %v", err) + } + if len(result.Findings) != 0 { + t.Fatalf("got %d findings, want 0", len(result.Findings)) + } +} + +// TestExecuteHTTPModulePayloadExpansion verifies payload templates reach the +// server and the matching response is captured. +func TestExecuteHTTPModulePayloadExpansion(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // only the "boom" payload triggers the vulnerable branch. + if r.URL.Query().Get("q") == "boom" { + _, _ = w.Write([]byte("error: sql syntax near boom")) + return + } + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() + + def := &YAMLModule{ + ID: "test-http-payload", + Type: TypeHTTP, + HTTP: &HTTPConfig{ + Paths: []string{"{{BaseURL}}/search?q={{payload}}"}, + Payloads: []string{"safe", "boom"}, + Matchers: []Matcher{ + {Type: "word", Part: "body", Words: []string{"sql syntax"}}, + }, + }, + } + + result, err := ExecuteHTTPModule(context.Background(), srv.URL, def, Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}) + if err != nil { + t.Fatalf("ExecuteHTTPModule: %v", err) + } + if len(result.Findings) != 1 { + t.Fatalf("got %d findings, want 1 (only boom payload)", len(result.Findings)) + } +} + +func TestExecuteHTTPModuleNoConfig(t *testing.T) { + def := &YAMLModule{ID: "x", Type: TypeHTTP} + if _, err := ExecuteHTTPModule(context.Background(), "http://h", def, Options{}); err == nil { + t.Fatal("expected error when HTTP config is nil") + } +} + +// TestExecuteHTTPModuleContextCancel pins the cancellation path. The dispatch +// loop selects between ctx.Done() and the concurrency semaphore, so a cancelled +// context can either short-circuit with ctx.Err() or let the in-flight request +// fail on the dead context. Both are correct: the contract is "never hang, never +// invent a finding", which is what we assert here rather than forcing one race +// winner (that made this test flaky under -count). +func TestExecuteHTTPModuleContextCancel(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + def := &YAMLModule{ + ID: "test-http-cancel", + Type: TypeHTTP, + HTTP: &HTTPConfig{ + Paths: []string{"{{BaseURL}}/a"}, + Matchers: []Matcher{{Type: "status", Status: []int{200}}}, + }, + } + + result, err := ExecuteHTTPModule(ctx, srv.URL, def, Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}) + if err != nil { + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled or nil", err) + } + return + } + // no error means the request was dispatched but failed on the dead context; + // either way a cancelled scan must not surface findings. + if len(result.Findings) != 0 { + t.Fatalf("cancelled scan produced %d findings, want 0", len(result.Findings)) + } +} + +// TestExecuteDNSModuleUnsupported pins the current behavior: DNS execution is +// not implemented and must signal it via ErrUnsupportedModuleType, not by +// quietly returning an empty (successful-looking) result. +func TestExecuteDNSModuleUnsupported(t *testing.T) { + def := &YAMLModule{ID: "dns-mod", Type: TypeDNS, DNS: &DNSConfig{Type: "A"}} + result, err := ExecuteDNSModule(context.Background(), "example.com", def, Options{}) + if result != nil { + t.Errorf("result = %v, want nil for unsupported type", result) + } + if !errors.Is(err, ErrUnsupportedModuleType) { + t.Fatalf("err = %v, want ErrUnsupportedModuleType", err) + } +} + +func TestExecuteTCPModuleUnsupported(t *testing.T) { + def := &YAMLModule{ID: "tcp-mod", Type: TypeTCP, TCP: &TCPConfig{Port: 22}} + result, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{}) + if result != nil { + t.Errorf("result = %v, want nil for unsupported type", result) + } + if !errors.Is(err, ErrUnsupportedModuleType) { + t.Fatalf("err = %v, want ErrUnsupportedModuleType", err) + } +} + +// TestWrapperExecuteRoutesByType confirms the Module wrapper dispatches each +// type to the right executor and propagates the unsupported-type sentinel. +func TestWrapperExecuteRoutesByType(t *testing.T) { + t.Run("dns routes to unsupported", func(t *testing.T) { + def := &YAMLModule{ID: "d", Type: TypeDNS, DNS: &DNSConfig{}} + w := newYAMLModuleWrapper(def, "d.yaml") + if _, err := w.Execute(context.Background(), "t", Options{}); !errors.Is(err, ErrUnsupportedModuleType) { + t.Fatalf("err = %v, want ErrUnsupportedModuleType", err) + } + }) + + t.Run("tcp routes to unsupported", func(t *testing.T) { + def := &YAMLModule{ID: "t", Type: TypeTCP, TCP: &TCPConfig{}} + w := newYAMLModuleWrapper(def, "t.yaml") + if _, err := w.Execute(context.Background(), "t", Options{}); !errors.Is(err, ErrUnsupportedModuleType) { + t.Fatalf("err = %v, want ErrUnsupportedModuleType", err) + } + }) + + t.Run("missing http config errors", func(t *testing.T) { + def := &YAMLModule{ID: "h", Type: TypeHTTP} + w := newYAMLModuleWrapper(def, "h.yaml") + if _, err := w.Execute(context.Background(), "t", Options{}); err == nil { + t.Fatal("expected error for missing http config") + } + }) + + t.Run("unknown type errors", func(t *testing.T) { + def := &YAMLModule{ID: "z", Type: ModuleType("bogus")} + w := newYAMLModuleWrapper(def, "z.yaml") + if _, err := w.Execute(context.Background(), "t", Options{}); err == nil { + t.Fatal("expected error for unknown module type") + } + }) +} + +func TestTruncateEvidence(t *testing.T) { + short := "short evidence" + if got := truncateEvidence(short); got != short { + t.Errorf("short evidence changed: %q", got) + } + + long := make([]byte, 600) + for i := range long { + long[i] = 'a' + } + got := truncateEvidence(string(long)) + // 500 chars of content plus the ellipsis marker. + if len(got) != 503 { + t.Errorf("truncated len = %d, want 503", len(got)) + } + if got[len(got)-3:] != "..." { + t.Errorf("truncated evidence missing ellipsis: %q", got[len(got)-3:]) + } +} diff --git a/internal/modules/loader_test.go b/internal/modules/loader_test.go new file mode 100644 index 0000000..1356a36 --- /dev/null +++ b/internal/modules/loader_test.go @@ -0,0 +1,269 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package modules + +import ( + "os" + "path/filepath" + "testing" +) + +// writeModule drops a yaml file into a temp dir and returns its path. +func writeModule(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write module: %v", err) + } + return path +} + +func TestParseYAMLModuleValid(t *testing.T) { + const doc = `id: example-http +type: http +info: + name: Example + author: azzie + severity: medium + description: a test module + tags: [test, demo] +http: + method: GET + paths: + - "{{BaseURL}}/admin" + matchers: + - type: status + status: [200] + - type: word + part: body + words: ["admin"] + condition: and + extractors: + - type: regex + name: token + part: body + regex: ["token=(\\w+)"] + group: 1 +` + dir := t.TempDir() + path := writeModule(t, dir, "ok.yaml", doc) + + def, err := ParseYAMLModule(path) + if err != nil { + t.Fatalf("ParseYAMLModule: %v", err) + } + if def.ID != "example-http" { + t.Errorf("id = %q, want example-http", def.ID) + } + if def.Type != TypeHTTP { + t.Errorf("type = %q, want http", def.Type) + } + if def.Info.Severity != "medium" { + t.Errorf("severity = %q, want medium", def.Info.Severity) + } + if def.HTTP == nil { + t.Fatal("http config not parsed") + } + if len(def.HTTP.Matchers) != 2 { + t.Errorf("got %d matchers, want 2", len(def.HTTP.Matchers)) + } + if len(def.HTTP.Extractors) != 1 || def.HTTP.Extractors[0].Group != 1 { + t.Errorf("extractor not parsed correctly: %+v", def.HTTP.Extractors) + } + if len(def.Info.Tags) != 2 { + t.Errorf("got %d tags, want 2", len(def.Info.Tags)) + } +} + +func TestParseYAMLModuleErrors(t *testing.T) { + dir := t.TempDir() + + tests := []struct { + name string + content string + }{ + { + name: "missing id", + content: "type: http\nhttp:\n paths: [\"/\"]\n", + }, + { + name: "missing type", + content: "id: no-type\nhttp:\n paths: [\"/\"]\n", + }, + { + name: "malformed yaml", + content: "id: bad\ntype: http\n paths: [unbalanced\n : nope\n", + }, + { + // a scalar where a mapping is expected must fail to unmarshal. + name: "type mismatch", + content: "id: bad-shape\ntype: http\nhttp: \"should-be-a-map\"\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := writeModule(t, dir, tt.name+".yaml", tt.content) + if _, err := ParseYAMLModule(path); err == nil { + t.Fatalf("expected error for %s", tt.name) + } + }) + } +} + +func TestParseYAMLModuleMissingFile(t *testing.T) { + if _, err := ParseYAMLModule(filepath.Join(t.TempDir(), "does-not-exist.yaml")); err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestYAMLModuleWrapperInfoAndType(t *testing.T) { + def := &YAMLModule{ + ID: "wrap-test", + Type: TypeHTTP, + Info: YAMLModuleInfo{ + Name: "Wrapped", + Author: "azzie", + Severity: "low", + Description: "desc", + Tags: []string{"a", "b"}, + }, + } + w := newYAMLModuleWrapper(def, "wrap.yaml") + + if w.Type() != TypeHTTP { + t.Errorf("Type() = %q, want http", w.Type()) + } + info := w.Info() + if info.ID != "wrap-test" || info.Name != "Wrapped" || info.Severity != "low" { + t.Errorf("Info() mismatch: %+v", info) + } + if len(info.Tags) != 2 { + t.Errorf("Info().Tags = %v, want 2 entries", info.Tags) + } +} + +// TestLoaderLoadAll exercises the directory walk: a valid module registers, a +// malformed one is skipped without aborting the walk. +func TestLoaderLoadAll(t *testing.T) { + Clear() + t.Cleanup(Clear) + + dir := t.TempDir() + writeModule(t, dir, "good.yaml", "id: good-mod\ntype: http\nhttp:\n paths: [\"{{BaseURL}}/\"]\n matchers:\n - type: status\n status: [200]\n") + writeModule(t, dir, "bad.yml", "id: bad-mod\n") // missing type -> skipped + writeModule(t, dir, "ignore.txt", "not a module") + + l := &Loader{builtinDir: dir, userDir: filepath.Join(dir, "nonexistent-user")} + if err := l.LoadAll(); err != nil { + t.Fatalf("LoadAll: %v", err) + } + + // only the good module loads; the malformed one is logged and skipped. + if l.Loaded() != 1 { + t.Errorf("Loaded() = %d, want 1", l.Loaded()) + } + if _, ok := Get("good-mod"); !ok { + t.Error("good-mod not registered") + } + if _, ok := Get("bad-mod"); ok { + t.Error("bad-mod should not have registered") + } +} + +func TestNewLoaderDirs(t *testing.T) { + l, err := NewLoader() + if err != nil { + t.Fatalf("NewLoader: %v", err) + } + if l.BuiltinDir() == "" { + t.Error("BuiltinDir is empty") + } + if l.UserDir() == "" { + t.Error("UserDir is empty") + } +} + +// TestRegistry exercises the package-level registry: register, get, dedupe by +// id, filter by tag and type, count and clear. +func TestRegistry(t *testing.T) { + Clear() + t.Cleanup(Clear) + + http1 := newYAMLModuleWrapper(&YAMLModule{ID: "h1", Type: TypeHTTP, Info: YAMLModuleInfo{Tags: []string{"web", "cve"}}}, "h1") + http2 := newYAMLModuleWrapper(&YAMLModule{ID: "h2", Type: TypeHTTP, Info: YAMLModuleInfo{Tags: []string{"web"}}}, "h2") + dns1 := newYAMLModuleWrapper(&YAMLModule{ID: "d1", Type: TypeDNS, Info: YAMLModuleInfo{Tags: []string{"dns"}}}, "d1") + + Register(http1) + Register(http2) + Register(dns1) + + if Count() != 3 { + t.Fatalf("Count() = %d, want 3", Count()) + } + + got, ok := Get("h1") + if !ok || got.Info().ID != "h1" { + t.Errorf("Get(h1) = %v, %v", got, ok) + } + if _, ok := Get("missing"); ok { + t.Error("Get(missing) should report not found") + } + + if n := len(ByType(TypeHTTP)); n != 2 { + t.Errorf("ByType(http) = %d, want 2", n) + } + if n := len(ByType(TypeDNS)); n != 1 { + t.Errorf("ByType(dns) = %d, want 1", n) + } + if n := len(ByTag("web")); n != 2 { + t.Errorf("ByTag(web) = %d, want 2", n) + } + if n := len(ByTag("cve")); n != 1 { + t.Errorf("ByTag(cve) = %d, want 1", n) + } + if n := len(ByTag("none")); n != 0 { + t.Errorf("ByTag(none) = %d, want 0", n) + } + if n := len(All()); n != 3 { + t.Errorf("All() = %d, want 3", n) + } + + // re-registering the same id overwrites rather than duplicating. + Register(newYAMLModuleWrapper(&YAMLModule{ID: "h1", Type: TypeHTTP}, "h1-v2")) + if Count() != 3 { + t.Errorf("Count() after re-register = %d, want 3", Count()) + } + + Clear() + if Count() != 0 { + t.Errorf("Count() after Clear = %d, want 0", Count()) + } +} + +// TestResultType pins the ScanResult interface bridge. +func TestResultType(t *testing.T) { + r := &Result{ModuleID: "abc"} + if r.ResultType() != "abc" { + t.Errorf("ResultType() = %q, want abc", r.ResultType()) + } +} + +// TestLoaderScriptStubNoop confirms the go-script loader is currently a no-op +// that registers nothing and returns no error. +func TestLoaderScriptStubNoop(t *testing.T) { + l := &Loader{} + if err := l.loadScript("anything.go"); err != nil { + t.Errorf("loadScript stub returned error: %v", err) + } +} diff --git a/internal/modules/matchers_test.go b/internal/modules/matchers_test.go new file mode 100644 index 0000000..ccdace5 --- /dev/null +++ b/internal/modules/matchers_test.go @@ -0,0 +1,465 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · 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: "