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/README.md b/README.md index 59359f3..530d041 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,14 @@ sif has a modular architecture. modules are defined in yaml and can be extended | flag | description | |------|-------------| | `-dirlist` | directory and file fuzzing (small/medium/large) | +| `-mc` | dirlist: match these status codes (comma list, e.g. 200,301) | +| `-fc` | dirlist: filter out these status codes (comma list) | +| `-fs` | dirlist: filter out responses of these body sizes (comma list) | +| `-fw` | dirlist: filter out responses with these word counts (comma list) | +| `-fr` | dirlist: filter out responses whose body matches this regex | +| `-ac` | dirlist: auto-calibrate the soft-404 wildcard baseline | +| `-w` | dirlist: custom wordlist (local file or url; overrides `-dirlist` size) | +| `-e` | dirlist: extensions appended to each word (comma list, e.g. php,bak,env) | | `-dnslist` | subdomain enumeration (small/medium/large) | | `-ports` | port scanning (common/full) | | `-nuclei` | vulnerability scanning with nuclei templates | @@ -180,6 +188,7 @@ sif has a modular architecture. modules are defined in yaml and can be extended | `-crawl` | web crawler (spider same-host links/scripts/forms) | | `-crawl-depth` | max crawl recursion depth (default 2) | | `-passive` | passive subdomain/url discovery (zero traffic to target) | +| `-probe` | live-host probe (status, title, server, redirect chain) | ### http options @@ -199,6 +208,22 @@ these apply to every outbound request across all scanners: a scanner that sets a header explicitly (e.g. an api key) always wins over the global default. +### report export + +write the run's findings out to a file for ci/cd or triage: + +| flag | description | +|------|-------------| +| `-sarif` | write a sarif 2.1.0 report to this file | +| `-markdown`, `-md` | write a markdown report to this file | + +```bash +# scan and emit both a sarif and markdown report +./sif -u https://example.com -headers -cors -sarif out.sarif -md out.md +``` + +sarif output is ingestable by github code scanning; markdown is a readable per-target summary. + ### yaml modules list available modules: diff --git a/docs/usage.md b/docs/usage.md index ae75048..5bf504e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -33,6 +33,42 @@ sizes: `small`, `medium`, `large` ./sif -u https://example.com -dirlist medium ``` +#### response filters + +modern apps serve a catch-all 200 for unknown routes, so a naive scan reports +every path. these ffuf-style filters cut the noise (a filter always wins over a +match): + +- `-mc ` - match only these status codes (comma list, e.g. `200,301`) +- `-fc ` - filter out these status codes +- `-fs ` - filter out responses of these body sizes +- `-fw ` - filter out responses with these word counts +- `-fr ` - filter out responses whose body matches this regex + +```bash +./sif -u https://example.com -dirlist medium -mc 200,301 -fs 1234 +``` + +#### wildcard calibration + +`-ac` probes a few paths that cannot exist, learns the soft-404 baseline +(status + size + words), and auto-drops any response matching it - so SPA +catch-all 200s stop flooding the output: + +```bash +./sif -u https://example.com -dirlist medium -ac +``` + +#### custom wordlists and extensions + +`-w ` overrides the size switch with your own list (local file or +remote url); `-e ` appends each extension to every word, keeping the bare +word too: + +```bash +./sif -u https://example.com -w /path/to/words.txt -e php,bak,env +``` + ### subdomain enumeration `-dnslist ` - enumerate subdomains @@ -206,6 +242,14 @@ keyless and zero traffic to the target itself - all lookups hit third-party feed ./sif -u https://example.com -passive ``` +### live-host probe + +`-probe` - check whether the target is alive and report its final status, page title, server header, content-length and the redirect chain it walked + +```bash +./sif -u https://example.com -probe +``` + ### whois lookup `-whois` - perform whois lookups @@ -327,6 +371,26 @@ cap outbound requests per second (0 = unlimited, default 0): ./sif -u https://example.com -rate-limit 20 ``` +## output options + +write the collected findings out to a file after the scan. both formats can be requested in the same run. + +### -sarif + +write a sarif 2.1.0 report (one run, tool `sif`, one result per finding). ingestable by github code scanning and other sarif consumers: + +```bash +./sif -u https://example.com -headers -cors -sarif out.sarif +``` + +### -md, --markdown + +write a readable markdown report grouped by target, then by module: + +```bash +./sif -u https://example.com -headers -cors -md report.md +``` + ## api options ### -api diff --git a/internal/config/config.go b/internal/config/config.go index bdd2f95..7eeea8d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,14 @@ import ( type Settings struct { Dirlist string + DirMatchCodes string // -mc dirlist: status codes to keep + DirFilterCodes string // -fc dirlist: status codes to drop + DirFilterSizes string // -fs dirlist: body sizes to drop + DirFilterWords string // -fw dirlist: word counts to drop + DirFilterRegex string // -fr dirlist: regex; body match drops response + DirCalibrate bool // -ac dirlist: auto-calibrate soft-404 baseline + DirWordlist string // -w dirlist: custom wordlist (file path or url) + DirExtensions string // -e dirlist: extensions appended to each word Dnslist string Debug bool LogDir string @@ -53,6 +61,9 @@ type Settings struct { Crawl bool CrawlDepth int Passive bool + Probe bool + SARIF string // path to write a sarif 2.1.0 report to ("" = off) + Markdown string // path to write a markdown report to ("" = off) Modules string // Comma-separated list of module IDs to run ModuleTags string // Run modules matching these tags AllModules bool // Run all loaded modules @@ -100,6 +111,14 @@ func Parse() *Settings { portScopes := goflags.AllowdTypes{"common": Common, "full": Full, "none": Nil} flagSet.CreateGroup("scans", "Scans", flagSet.EnumVar(&settings.Dirlist, "dirlist", Nil, "Directory fuzzing scan size (small/medium/large)", listSizes), + flagSet.StringVar(&settings.DirMatchCodes, "mc", "", "Dirlist: match these status codes (comma list, e.g. 200,301)"), + flagSet.StringVar(&settings.DirFilterCodes, "fc", "", "Dirlist: filter out these status codes (comma list)"), + flagSet.StringVar(&settings.DirFilterSizes, "fs", "", "Dirlist: filter out responses of these body sizes (comma list)"), + flagSet.StringVar(&settings.DirFilterWords, "fw", "", "Dirlist: filter out responses with these word counts (comma list)"), + flagSet.StringVar(&settings.DirFilterRegex, "fr", "", "Dirlist: filter out responses whose body matches this regex"), + flagSet.BoolVar(&settings.DirCalibrate, "ac", false, "Dirlist: auto-calibrate the soft-404 wildcard baseline"), + flagSet.StringVar(&settings.DirWordlist, "w", "", "Dirlist: custom wordlist (local file path or url; overrides -dirlist size)"), + flagSet.StringVar(&settings.DirExtensions, "e", "", "Dirlist: extensions appended to each word (comma list, e.g. php,bak,env)"), flagSet.EnumVar(&settings.Dnslist, "dnslist", Nil, "DNS fuzzing scan size (small/medium/large)", listSizes), flagSet.EnumVar(&settings.Ports, "ports", Nil, "Port scanning scope (common/full)", portScopes), flagSet.BoolVar(&settings.Dorking, "dork", false, "Enable Google dorking"), @@ -124,6 +143,7 @@ func Parse() *Settings { flagSet.BoolVar(&settings.Crawl, "crawl", false, "Enable web crawling (spider same-host links/scripts/forms)"), flagSet.IntVar(&settings.CrawlDepth, "crawl-depth", defaultCrawlDepth, "Max crawl recursion depth"), flagSet.BoolVar(&settings.Passive, "passive", false, "Enable passive subdomain/url discovery (zero traffic to target)"), + flagSet.BoolVar(&settings.Probe, "probe", false, "Probe the target for liveness (status, title, server, redirect chain)"), ) flagSet.CreateGroup("runtime", "Runtime", @@ -141,6 +161,11 @@ func Parse() *Settings { flagSet.IntVar(&settings.RateLimit, "rate-limit", 0, "Max requests per second (0 = unlimited)"), ) + flagSet.CreateGroup("output", "Output", + flagSet.StringVar(&settings.SARIF, "sarif", "", "Write a SARIF 2.1.0 report to this file"), + flagSet.StringVarP(&settings.Markdown, "markdown", "md", "", "Write a markdown report to this file"), + ) + flagSet.CreateGroup("api", "API", flagSet.BoolVar(&settings.ApiMode, "api", false, "Enable API mode. Only useful for internal lunchcat usage"), ) 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: "